Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930ed954ec | ||
|
|
402f590163 | ||
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 |
217
.github/workflows/release-on-version.yml
vendored
217
.github/workflows/release-on-version.yml
vendored
@@ -2,164 +2,83 @@
|
|||||||
name: Release on version.js update
|
name: Release on version.js update
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
paths:
|
|
||||||
- public/js/version.js
|
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
description: "Ref (branch/sha) to build from (default: master)"
|
||||||
required: false
|
required: false
|
||||||
version:
|
version:
|
||||||
description: "Explicit version tag to release (e.g., v1.8.6). If empty, auto-detect."
|
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
delay:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Delay 10 minutes
|
|
||||||
run: sleep 600
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: delay
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
|
||||||
if: >
|
|
||||||
github.event_name == 'push' ||
|
github.event_name == 'push' ||
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch'
|
||||||
(github.event_name == 'workflow_run' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
|
||||||
|
|
||||||
# Use run_id for a stable, unique key
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.run_id }}
|
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (fetch all)
|
- name: Resolve source ref
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Ensure tags + master available
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
git fetch --tags --force --prune --quiet
|
|
||||||
git fetch origin master --quiet
|
|
||||||
|
|
||||||
- name: Resolve source ref + (maybe) version
|
|
||||||
id: pickref
|
id: pickref
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Defaults
|
|
||||||
REF=""
|
|
||||||
VER=""
|
|
||||||
SRC=""
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
# manual run
|
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||||
REF_IN="${{ github.event.inputs.ref }}"
|
REF_IN="${{ github.event.inputs.ref }}"
|
||||||
VER_IN="${{ github.event.inputs.version }}"
|
|
||||||
if [[ -n "$REF_IN" ]]; then
|
|
||||||
# Try branch/sha; fetch branch if needed
|
|
||||||
git fetch origin "$REF_IN" --quiet || true
|
|
||||||
if REF_SHA="$(git rev-parse --verify --quiet "$REF_IN")"; then
|
|
||||||
REF="$REF_SHA"
|
|
||||||
else
|
|
||||||
echo "Provided ref '$REF_IN' not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
REF="$(git rev-parse origin/master)"
|
REF_IN="master"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$VER_IN" ]]; then
|
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||||
VER="$VER_IN"
|
REF="$REF_IN"
|
||||||
SRC="manual-version"
|
else
|
||||||
|
REF="$REF_IN"
|
||||||
fi
|
fi
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
||||||
REF="${{ github.event.workflow_run.head_sha }}"
|
|
||||||
else
|
else
|
||||||
REF="${{ github.sha }}"
|
REF="${{ github.sha }}"
|
||||||
fi
|
fi
|
||||||
|
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Using ref=$REF"
|
||||||
|
|
||||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||||
if [[ -z "$VER" ]]; then
|
|
||||||
# Search recent history reachable from REF
|
|
||||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
|
||||||
if [[ -n "$BOT_SHA" ]]; then
|
|
||||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
|
||||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
|
||||||
if [[ -n "$BOT_VER" ]]; then
|
|
||||||
VER="$BOT_VER"
|
|
||||||
REF="$BOT_SHA" # build/tag from the bump commit
|
|
||||||
SRC="bot-commit"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Output
|
|
||||||
REF_SHA="$(git rev-parse "$REF")"
|
|
||||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
|
||||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
|
||||||
|
|
||||||
- name: Checkout chosen ref
|
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ steps.pickref.outputs.ref }}
|
ref: ${{ steps.pickref.outputs.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
- name: Assert ref is on master
|
persist-credentials: false
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Debug version.js provenance
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "version.js last-change commit: $(git log -n1 --pretty='%h %s' -- public/js/version.js || echo 'none')"
|
|
||||||
sed -n '1,20p' public/js/version.js || true
|
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: ver
|
id: ver
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Prefer pre-resolved version (manual input or bot commit)
|
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
VER="${{ github.event.inputs.version }}"
|
||||||
VER="${{ steps.pickref.outputs.preversion }}"
|
else
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
if [[ ! -f public/js/version.js ]]; then
|
||||||
echo "Parsed version (pre-resolved): $VER"
|
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Fallback to version.js
|
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
if [[ -z "$VER" ]]; then
|
||||||
if [[ -z "$VER" ]]; then
|
echo "Could not parse APP_VERSION from public/js/version.js" >&2
|
||||||
echo "Could not parse APP_VERSION from version.js" >&2
|
exit 1
|
||||||
exit 1
|
fi
|
||||||
fi
|
fi
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||||
echo "Parsed version (file): $VER"
|
echo "Detected version: $VER"
|
||||||
|
|
||||||
- name: Skip if tag already exists
|
- name: Skip if tag already exists
|
||||||
id: tagcheck
|
id: tagcheck
|
||||||
@@ -173,7 +92,7 @@ jobs:
|
|||||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Prep stamper script
|
- name: Prepare stamp script
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -181,7 +100,7 @@ jobs:
|
|||||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||||
chmod +x scripts/stamp-assets.sh
|
chmod +x scripts/stamp-assets.sh
|
||||||
|
|
||||||
- name: Build zip artifact (stamped)
|
- name: Build stamped staging tree
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -195,27 +114,67 @@ jobs:
|
|||||||
./ staging/
|
./ staging/
|
||||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/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'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(pwd)/staging"
|
ROOT="$(pwd)/staging"
|
||||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
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
|
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||||
echo "---- DEBUG (show 10 hits with context) ----"
|
echo "Unreplaced placeholders found in staging." >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -223,7 +182,7 @@ jobs:
|
|||||||
VER="${{ steps.ver.outputs.version }}"
|
VER="${{ steps.ver.outputs.version }}"
|
||||||
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||||
|
|
||||||
- name: Compute SHA-256 checksum
|
- name: Compute SHA-256
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
id: sum
|
id: sum
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -268,9 +227,9 @@ jobs:
|
|||||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||||
fi
|
fi
|
||||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
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'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
196
CHANGELOG.md
196
CHANGELOG.md
@@ -1,5 +1,201 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## Changes 11/9/2025 (v1.9.1)
|
||||||
|
|
||||||
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -21,9 +21,7 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
|
|||||||
|
|
||||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
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.
|
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||||
|
|
||||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
|
||||||
|
|
||||||
**10/25/2025 Video demo:**
|
**10/25/2025 Video demo:**
|
||||||
|
|
||||||
@@ -326,21 +324,6 @@ https://your-host/webdav.php/
|
|||||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||||
- Click **Finish**.
|
- 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.
|
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -404,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
|||||||
|
|
||||||
## Security posture
|
## Security posture
|
||||||
|
|
||||||
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
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.
|
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.
|
If you’re running ≤1.4.x, please upgrade.
|
||||||
@@ -471,7 +456,7 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,245 +1,18 @@
|
|||||||
<?php
|
<?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);
|
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 __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
|
||||||
require_once PROJECT_ROOT . '/src/models/FolderModel.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 ---
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
$username = $_SESSION['username'] ?? '';
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
if ($username === '') {
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
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);
|
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/isEmpty.php
|
// Fast ACL-aware peek for tree icons/chevrons
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
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 __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
// Snapshot then release session lock so parallel requests don’t block
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
$user = (string)($_SESSION['username'] ?? '');
|
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
$perms = [
|
$perms = [
|
||||||
'role' => $_SESSION['role'] ?? null,
|
'role' => $_SESSION['role'] ?? null,
|
||||||
'admin' => $_SESSION['admin'] ?? null,
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||||
|
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||||
];
|
];
|
||||||
@session_write_close();
|
@session_write_close();
|
||||||
|
|
||||||
// Input
|
|
||||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
$folder = str_replace('\\', '/', trim($folder));
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
|
|
||||||
// Delegate to controller (model handles ACL + path safety)
|
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);
|
||||||
$result = FolderController::stats($folder, $user, $perms);
|
|
||||||
|
|
||||||
// Always return a compact JSON object like before
|
|
||||||
echo json_encode([
|
|
||||||
'folders' => (int)($result['folders'] ?? 0),
|
|
||||||
'files' => (int)($result['files'] ?? 0),
|
|
||||||
]);
|
|
||||||
31
public/api/folder/listChildren.php
Normal file
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);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -277,14 +277,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
<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);">
|
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;">
|
||||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
<style>
|
||||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
/* Dark mode polish */
|
||||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
body.dark-mode #folderHelpTooltip {
|
||||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
|
||||||
subfolder.</li>
|
}
|
||||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
|
||||||
the appropriate button.</li>
|
#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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,6 +365,10 @@
|
|||||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
<span data-i18n-key="create_folder">Create folder</span>
|
<span data-i18n-key="create_folder">Create folder</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
<span data-i18n-key="upload">Upload file(s)</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
@@ -463,6 +477,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 id="removeUserModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
||||||
@@ -494,6 +528,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
|||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||||
import { initTagSearch } from './fileTags.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 { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||||
import { loadAdminConfigFunc } from './auth.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
|
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||||
const _nativeFetch = window.fetch.bind(window);
|
const _nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
@@ -84,25 +98,53 @@ export function initializeApp() {
|
|||||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
|
|
||||||
|
|
||||||
// Hook DnD relay from fileList area into upload area
|
// Hook DnD relay from fileList area into upload area
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileList');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
|
||||||
if (fileListArea && uploadArea) {
|
if (fileListArea) {
|
||||||
|
let hoverTimer = null;
|
||||||
|
|
||||||
fileListArea.addEventListener('dragover', e => {
|
fileListArea.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.add('drop-hover');
|
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.addEventListener('dragleave', () => {
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
});
|
});
|
||||||
fileListArea.addEventListener('drop', e => {
|
|
||||||
|
fileListArea.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
dataTransfer: e.dataTransfer,
|
|
||||||
bubbles: true,
|
// 1) open the same modal that the Create menu uses
|
||||||
cancelable: true
|
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 }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
|||||||
|
|
||||||
export function buildFileTableHeader(sortOrder) {
|
export function buildFileTableHeader(sortOrder) {
|
||||||
return `
|
return `
|
||||||
<table class="table">
|
<table class="table filr-table table-hover table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||||
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
|
|||||||
const row = checkbox.closest('tr');
|
const row = checkbox.closest('tr');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
row.classList.add('row-selected');
|
row.classList.add('row-selected', 'selected');
|
||||||
} else {
|
} else {
|
||||||
row.classList.remove('row-selected');
|
row.classList.remove('row-selected', 'selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function handleDeleteSelected(e) {
|
|||||||
showToast("no_files_selected");
|
showToast("no_files_selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||||
const count = window.filesToDelete.length;
|
const count = window.filesToDelete.length;
|
||||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||||
@@ -21,6 +20,52 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
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 () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||||
if (cancelDelete) {
|
if (cancelDelete) {
|
||||||
@@ -829,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const menu = document.getElementById('createMenu');
|
const menu = document.getElementById('createMenu');
|
||||||
const fileOpt = document.getElementById('createFileOption');
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
const folderOpt = document.getElementById('createFolderOption');
|
const folderOpt = document.getElementById('createFolderOption');
|
||||||
|
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||||
|
|
||||||
// Toggle dropdown on click
|
// Toggle dropdown on click
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -853,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
menu.style.display = 'none';
|
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;
|
window.renameFile = renameFile;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +1,246 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.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 { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.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) {
|
const MENU_ID = 'fileContextMenu';
|
||||||
let menu = document.getElementById("fileContextMenu");
|
|
||||||
if (!menu) {
|
function qMenu() { return document.getElementById(MENU_ID); }
|
||||||
menu = document.createElement("div");
|
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
|
||||||
menu.id = "fileContextMenu";
|
|
||||||
menu.style.position = "fixed";
|
// One-time: localize labels
|
||||||
menu.style.backgroundColor = "#fff";
|
function localizeMenu() {
|
||||||
menu.style.border = "1px solid #ccc";
|
const m = qMenu(); if (!m) return;
|
||||||
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
|
const map = {
|
||||||
menu.style.zIndex = "9999";
|
'create_file': 'create_file',
|
||||||
menu.style.padding = "5px 0";
|
'delete_selected': 'delete_selected',
|
||||||
menu.style.minWidth = "150px";
|
'copy_selected': 'copy_selected',
|
||||||
document.body.appendChild(menu);
|
'move_selected': 'move_selected',
|
||||||
}
|
'download_zip': 'download_zip',
|
||||||
menu.innerHTML = "";
|
'extract_zip': 'extract_zip',
|
||||||
menuItems.forEach(item => {
|
'tag_selected': 'tag_selected',
|
||||||
let menuItem = document.createElement("div");
|
'preview': 'preview',
|
||||||
menuItem.textContent = item.label;
|
'edit': 'edit',
|
||||||
menuItem.style.padding = "5px 15px";
|
'rename': 'rename',
|
||||||
menuItem.style.cursor = "pointer";
|
'tag_file': 'tag_file'
|
||||||
menuItem.addEventListener("mouseover", () => {
|
};
|
||||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
});
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
menuItem.addEventListener("mouseout", () => {
|
if (el) setText(el, key);
|
||||||
menuItem.style.backgroundColor = "";
|
|
||||||
});
|
|
||||||
menuItem.addEventListener("click", () => {
|
|
||||||
item.action();
|
|
||||||
hideFileContextMenu();
|
|
||||||
});
|
|
||||||
menu.appendChild(menuItem);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
menu.style.left = x + "px";
|
// Show/hide items based on selection state
|
||||||
menu.style.top = y + "px";
|
function configureVisibility({ any, one, many, anyZip, canEdit }) {
|
||||||
menu.style.display = "block";
|
const m = qMenu(); if (!m) return;
|
||||||
|
|
||||||
const menuRect = menu.getBoundingClientRect();
|
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
if (menuRect.bottom > viewportHeight) {
|
show(m.querySelectorAll('[data-when="always"]'), true);
|
||||||
let newTop = viewportHeight - menuRect.height;
|
show(m.querySelectorAll('[data-when="any"]'), any);
|
||||||
if (newTop < 0) newTop = 0;
|
show(m.querySelectorAll('[data-when="one"]'), one);
|
||||||
menu.style.top = newTop + "px";
|
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() {
|
export function hideFileContextMenu() {
|
||||||
const menu = document.getElementById("fileContextMenu");
|
const m = qMenu();
|
||||||
if (menu) {
|
if (m) m.hidden = true;
|
||||||
menu.style.display = "none";
|
}
|
||||||
}
|
|
||||||
|
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) {
|
export function fileListContextMenuHandler(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
let row = e.target.closest("tr");
|
// Check row if needed
|
||||||
|
const row = e.target.closest('tr');
|
||||||
if (row) {
|
if (row) {
|
||||||
const checkbox = row.querySelector(".file-checkbox");
|
const cb = row.querySelector('.file-checkbox');
|
||||||
if (checkbox && !checkbox.checked) {
|
if (cb && !cb.checked) {
|
||||||
checkbox.checked = true;
|
cb.checked = true;
|
||||||
updateRowHighlight(checkbox);
|
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 = [
|
// Stash for click handlers
|
||||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
window.__filr_ctx_state = state;
|
||||||
{ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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() {
|
export function bindFileListContextMenu() {
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const container = document.getElementById('fileList');
|
||||||
if (fileListContainer) {
|
const menu = qMenu();
|
||||||
fileListContainer.oncontextmenu = fileListContextMenuHandler;
|
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) {
|
// Rebind after table render (keeps your original behavior)
|
||||||
const menu = document.getElementById("fileContextMenu");
|
|
||||||
if (menu && menu.style.display === "block") {
|
|
||||||
hideFileContextMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebind context menu after file table render.
|
|
||||||
(function () {
|
(function () {
|
||||||
const originalRenderFileTable = window.renderFileTable;
|
const orig = window.renderFileTable;
|
||||||
window.renderFileTable = function (folder) {
|
if (typeof orig === 'function') {
|
||||||
originalRenderFileTable(folder);
|
window.renderFileTable = function (folder) {
|
||||||
bindFileListContextMenu();
|
orig(folder);
|
||||||
};
|
bindFileListContextMenu();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// If not present yet, bind once DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -160,7 +160,7 @@ function ensureMediaModal() {
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const styles = getComputedStyle(root);
|
const styles = getComputedStyle(root);
|
||||||
const isDark = root.classList.contains('dark-mode');
|
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 textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
|
||||||
|
|
||||||
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
|
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
|
||||||
|
|||||||
@@ -1,172 +1,214 @@
|
|||||||
// fileTags.js
|
// fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
|
||||||
// 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.
|
|
||||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function openTagModal(file) {
|
// -------------------- state --------------------
|
||||||
// Create the modal element.
|
let __singleInit = false;
|
||||||
let modal = document.createElement('div');
|
let __multiInit = false;
|
||||||
modal.id = 'tagModal';
|
let currentFile = null;
|
||||||
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';
|
|
||||||
|
|
||||||
updateCustomTagDropdown();
|
// Global store (preserve existing behavior)
|
||||||
|
window.globalTags = window.globalTags || [];
|
||||||
document.getElementById('closeTagModal').addEventListener('click', () => {
|
if (localStorage.getItem('globalTags')) {
|
||||||
modal.remove();
|
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------- ensure DOM (create-once-if-missing) --------------------
|
||||||
* Open a modal to tag multiple files.
|
function ensureSingleTagModal() {
|
||||||
* @param {Array} files - Array of file objects to tag.
|
// de-dupe if something already injected multiples
|
||||||
*/
|
const all = document.querySelectorAll('#tagModal');
|
||||||
export function openMultiTagModal(files) {
|
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
|
||||||
let modal = document.createElement('div');
|
|
||||||
modal.id = 'multiTagModal';
|
let modal = document.getElementById('tagModal');
|
||||||
modal.className = 'modal';
|
if (!modal) {
|
||||||
modal.innerHTML = `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="modal-content" style="width: 450px; max-width:90vw;">
|
<div id="tagModal" class="modal" style="display:none">
|
||||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="modal-content" style="width:450px; max-width:90vw;">
|
||||||
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
<h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
</div>
|
${t('tag_file')}
|
||||||
<div class="modal-body" style="margin-top:10px;">
|
</h3>
|
||||||
<label for="multiTagNameInput">Tag Name:</label>
|
<span id="closeTagModal" class="editor-close-btn">×</span>
|
||||||
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
</div>
|
||||||
<br><br>
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
<label for="multiTagColorInput">Tag Color:</label>
|
<label for="tagNameInput">${t('tag_name')}</label>
|
||||||
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
<input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
|
||||||
<br><br>
|
<br><br>
|
||||||
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
<label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
|
||||||
<!-- Custom tag options will be populated here -->
|
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
</div>
|
<br><br>
|
||||||
<br>
|
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
|
||||||
<div style="text-align:right;">
|
<br>
|
||||||
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
|
<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>
|
</div>
|
||||||
</div>
|
`);
|
||||||
`;
|
modal = document.getElementById('tagModal');
|
||||||
document.body.appendChild(modal);
|
}
|
||||||
modal.style.display = 'block';
|
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', () => {
|
let modal = document.getElementById('multiTagModal');
|
||||||
modal.remove();
|
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) => {
|
// Input filter for dropdown
|
||||||
updateMultiCustomTagDropdown(e.target.value);
|
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', () => {
|
__singleInit = true;
|
||||||
const tagName = document.getElementById('multiTagNameInput').value.trim();
|
}
|
||||||
const tagColor = document.getElementById('multiTagColorInput').value;
|
|
||||||
if (!tagName) {
|
function initMultiModalOnce() {
|
||||||
alert('Please enter a tag name.');
|
if (__multiInit) return;
|
||||||
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 => {
|
files.forEach(file => {
|
||||||
addTagToFile(file, { name: tagName, color: tagColor });
|
addTagToFile(file, { name: tagName, color: tagColor });
|
||||||
updateFileRowTagDisplay(file);
|
updateFileRowTagDisplay(file);
|
||||||
saveFileTags(file);
|
saveFileTags(file);
|
||||||
});
|
});
|
||||||
modal.remove();
|
|
||||||
if (window.viewMode === 'gallery') {
|
hideMultiTagModal();
|
||||||
renderGalleryView(window.currentFolder);
|
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
|
||||||
} else {
|
else renderFileTable(window.currentFolder);
|
||||||
renderFileTable(window.currentFolder);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
__multiInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------- open/close APIs --------------------
|
||||||
* Update the custom dropdown for multi-tag modal.
|
export function openTagModal(file) {
|
||||||
* Similar to updateCustomTagDropdown but includes a remove icon.
|
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 = "") {
|
function updateMultiCustomTagDropdown(filterText = "") {
|
||||||
const dropdown = document.getElementById("multiCustomTagDropdown");
|
const dropdown = document.getElementById("multiCustomTagDropdown");
|
||||||
if (!dropdown) return;
|
if (!dropdown) return;
|
||||||
dropdown.innerHTML = "";
|
dropdown.innerHTML = "";
|
||||||
let tags = window.globalTags || [];
|
let tags = window.globalTags || [];
|
||||||
if (filterText) {
|
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.style.cursor = "pointer";
|
item.style.cursor = "pointer";
|
||||||
item.style.padding = "5px";
|
item.style.padding = "5px";
|
||||||
item.style.borderBottom = "1px solid #eee";
|
item.style.borderBottom = "1px solid #eee";
|
||||||
// Display colored square and tag name with remove icon.
|
|
||||||
item.innerHTML = `
|
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>
|
<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)}
|
${escapeHTML(tag.name)}
|
||||||
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
|
|||||||
`;
|
`;
|
||||||
item.addEventListener("click", function(e) {
|
item.addEventListener("click", function(e) {
|
||||||
if (e.target.classList.contains("global-remove")) return;
|
if (e.target.classList.contains("global-remove")) return;
|
||||||
document.getElementById("multiTagNameInput").value = tag.name;
|
const n = document.getElementById("multiTagNameInput");
|
||||||
document.getElementById("multiTagColorInput").value = tag.color;
|
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){
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
|
|||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
});
|
});
|
||||||
} else {
|
} 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;
|
if (!dropdown) return;
|
||||||
dropdown.innerHTML = "";
|
dropdown.innerHTML = "";
|
||||||
let tags = window.globalTags || [];
|
let tags = window.globalTags || [];
|
||||||
if (filterText) {
|
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
|
|||||||
`;
|
`;
|
||||||
item.addEventListener("click", function(e){
|
item.addEventListener("click", function(e){
|
||||||
if (e.target.classList.contains('global-remove')) return;
|
if (e.target.classList.contains('global-remove')) return;
|
||||||
document.getElementById("tagNameInput").value = tag.name;
|
const n = document.getElementById("tagNameInput");
|
||||||
document.getElementById("tagColorInput").value = tag.color;
|
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){
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
|
|||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
});
|
});
|
||||||
} else {
|
} 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.
|
// Update the modal display to show current tags on the file.
|
||||||
function updateTagModalDisplay(file) {
|
function updateTagModalDisplay(file) {
|
||||||
const container = document.getElementById('currentTags');
|
const container = document.getElementById('currentTags');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<strong>Current Tags:</strong> ';
|
container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
|
||||||
if (file.tags && file.tags.length > 0) {
|
if (file?.tags?.length) {
|
||||||
file.tags.forEach(tag => {
|
file.tags.forEach(tag => {
|
||||||
const tagElem = document.createElement('span');
|
const tagElem = document.createElement('span');
|
||||||
tagElem.textContent = tag.name;
|
tagElem.textContent = tag.name;
|
||||||
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
|
|||||||
tagElem.style.borderRadius = '3px';
|
tagElem.style.borderRadius = '3px';
|
||||||
tagElem.style.display = 'inline-block';
|
tagElem.style.display = 'inline-block';
|
||||||
tagElem.style.position = 'relative';
|
tagElem.style.position = 'relative';
|
||||||
|
|
||||||
const removeIcon = document.createElement('span');
|
const removeIcon = document.createElement('span');
|
||||||
removeIcon.textContent = ' ✕';
|
removeIcon.textContent = ' ✕';
|
||||||
removeIcon.style.fontWeight = 'bold';
|
removeIcon.style.fontWeight = 'bold';
|
||||||
removeIcon.style.marginLeft = '3px';
|
removeIcon.style.marginLeft = '3px';
|
||||||
removeIcon.style.cursor = 'pointer';
|
removeIcon.style.cursor = 'pointer';
|
||||||
|
|
||||||
removeIcon.addEventListener('click', (e) => {
|
removeIcon.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeTagFromFile(file, tag.name);
|
removeTagFromFile(file, tag.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
tagElem.appendChild(removeIcon);
|
tagElem.appendChild(removeIcon);
|
||||||
container.appendChild(tagElem);
|
container.appendChild(tagElem);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML += 'None';
|
container.innerHTML += (t('none') || 'None');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTagFromFile(file, tagName) {
|
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);
|
updateTagModalDisplay(file);
|
||||||
updateFileRowTagDisplay(file);
|
updateFileRowTagDisplay(file);
|
||||||
saveFileTags(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) {
|
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));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
saveGlobalTagRemoval(tagName);
|
saveGlobalTagRemoval(tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Save global tag removal to the server.
|
|
||||||
function saveGlobalTagRemoval(tagName) {
|
function saveGlobalTagRemoval(tagName) {
|
||||||
fetch("/api/file/saveFileTag.php", {
|
fetch("/api/file/saveFileTag.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
folder: "root",
|
|
||||||
file: "global",
|
|
||||||
deleteGlobal: true,
|
|
||||||
tagToDelete: tagName,
|
|
||||||
tags: []
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success && data.globalTags) {
|
||||||
console.log("Global tag removed:", tagName);
|
window.globalTags = data.globalTags;
|
||||||
if (data.globalTags) {
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
window.globalTags = data.globalTags;
|
updateCustomTagDropdown();
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
updateMultiCustomTagDropdown();
|
||||||
updateCustomTagDropdown();
|
} else if (!data.success) {
|
||||||
updateMultiCustomTagDropdown();
|
console.error("Error removing global tag:", data.error);
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
console.error("Error removing global tag:", data.error);
|
.catch(err => console.error("Error removing global tag:", err));
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error removing global tag:", err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global store for reusable tags.
|
// -------------------- exports kept from your original --------------------
|
||||||
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.
|
|
||||||
export function loadGlobalTags() {
|
export function loadGlobalTags() {
|
||||||
fetch("/api/file/getFileTag.php", { credentials: "include" })
|
fetch("/api/file/getFileTag.php", { credentials: "include" })
|
||||||
.then(response => {
|
.then(r => r.ok ? r.json() : [])
|
||||||
if (!response.ok) {
|
|
||||||
// If the file doesn't exist, assume there are no global tags.
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
window.globalTags = data;
|
window.globalTags = data || [];
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
|
|||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadGlobalTags();
|
loadGlobalTags();
|
||||||
|
|
||||||
// Add (or update) a tag in the file object.
|
|
||||||
export function addTagToFile(file, tag) {
|
export function addTagToFile(file, tag) {
|
||||||
if (!file.tags) {
|
if (!file.tags) 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 exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
|
||||||
if (exists) {
|
const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
exists.color = tag.color;
|
|
||||||
} else {
|
|
||||||
file.tags.push(tag);
|
|
||||||
}
|
|
||||||
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
|
||||||
if (!globalExists) {
|
if (!globalExists) {
|
||||||
window.globalTags.push(tag);
|
window.globalTags.push(tag);
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the file row (in table view) to show tag badges.
|
|
||||||
export function updateFileRowTagDisplay(file) {
|
export function updateFileRowTagDisplay(file) {
|
||||||
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
||||||
console.log('Updating tags for rows:', rows);
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
let cell = row.querySelector('.file-name-cell');
|
let cell = row.querySelector('.file-name-cell');
|
||||||
if (cell) {
|
if (!cell) return;
|
||||||
let badgeContainer = cell.querySelector('.tag-badges');
|
let badgeContainer = cell.querySelector('.tag-badges');
|
||||||
if (!badgeContainer) {
|
if (!badgeContainer) {
|
||||||
badgeContainer = document.createElement('div');
|
badgeContainer = document.createElement('div');
|
||||||
badgeContainer.className = 'tag-badges';
|
badgeContainer.className = 'tag-badges';
|
||||||
badgeContainer.style.display = 'inline-block';
|
badgeContainer.style.display = 'inline-block';
|
||||||
badgeContainer.style.marginLeft = '5px';
|
badgeContainer.style.marginLeft = '5px';
|
||||||
cell.appendChild(badgeContainer);
|
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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() {
|
export function initTagSearch() {
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
if (searchInput) {
|
if (!searchInput) return;
|
||||||
let tagSearchInput = document.getElementById('tagSearchInput');
|
let tagSearchInput = document.getElementById('tagSearchInput');
|
||||||
if (!tagSearchInput) {
|
if (!tagSearchInput) {
|
||||||
tagSearchInput = document.createElement('input');
|
tagSearchInput = document.createElement('input');
|
||||||
tagSearchInput.id = 'tagSearchInput';
|
tagSearchInput.id = 'tagSearchInput';
|
||||||
tagSearchInput.placeholder = 'Filter by tag';
|
tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
|
||||||
tagSearchInput.style.marginLeft = '10px';
|
tagSearchInput.style.marginLeft = '10px';
|
||||||
tagSearchInput.style.padding = '5px';
|
tagSearchInput.style.padding = '5px';
|
||||||
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
||||||
tagSearchInput.addEventListener('input', () => {
|
tagSearchInput.addEventListener('input', () => {
|
||||||
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
||||||
if (window.currentFolder) {
|
if (window.currentFolder) renderFileTable(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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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() {
|
function updateGlobalTagList() {
|
||||||
const dataList = document.getElementById("globalTagList");
|
const dataList = document.getElementById("globalTagList");
|
||||||
if (dataList) {
|
if (!dataList) return;
|
||||||
dataList.innerHTML = "";
|
dataList.innerHTML = "";
|
||||||
window.globalTags.forEach(tag => {
|
(window.globalTags || []).forEach(tag => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = tag.name;
|
option.value = tag.name;
|
||||||
dataList.appendChild(option);
|
dataList.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
||||||
const folder = file.folder || "root";
|
const folder = file.folder || "root";
|
||||||
const payload = {
|
const payload = deleteGlobal && tagToDelete ? {
|
||||||
folder: folder,
|
folder: "root",
|
||||||
file: file.name,
|
file: "global",
|
||||||
tags: file.tags
|
deleteGlobal: true,
|
||||||
};
|
tagToDelete,
|
||||||
if (deleteGlobal && tagToDelete) {
|
tags: []
|
||||||
payload.file = "global";
|
} : { folder, file: file.name, tags: file.tags };
|
||||||
payload.deleteGlobal = true;
|
|
||||||
payload.tagToDelete = tagToDelete;
|
|
||||||
}
|
|
||||||
fetch("/api/file/saveFileTag.php", {
|
fetch("/api/file/saveFileTag.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log("Tags saved:", data);
|
|
||||||
if (data.globalTags) {
|
if (data.globalTags) {
|
||||||
window.globalTags = data.globalTags;
|
window.globalTags = data.globalTags;
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
}
|
}
|
||||||
|
updateGlobalTagList();
|
||||||
} else {
|
} else {
|
||||||
console.error("Error saving tags:", data.error);
|
console.error("Error saving tags:", data.error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => console.error("Error saving tags:", err));
|
||||||
console.error("Error saving tags:", err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -318,7 +318,19 @@ const translations = {
|
|||||||
"reset_default": "Reset",
|
"reset_default": "Reset",
|
||||||
"save_color": "Save",
|
"save_color": "Save",
|
||||||
"folder_color_saved": "Folder color saved.",
|
"folder_color_saved": "Folder color saved.",
|
||||||
"folder_color_cleared": "Folder color reset."
|
"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."
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -6,6 +6,176 @@ import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
|||||||
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.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)
|
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
@@ -456,7 +626,7 @@ async function initResumableUpload() {
|
|||||||
chunkSize: 1.5 * 1024 * 1024,
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
simultaneousUploads: 3,
|
simultaneousUploads: 3,
|
||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: true,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
query: () => ({
|
query: () => ({
|
||||||
@@ -493,6 +663,11 @@ async function initResumableUpload() {
|
|||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
}
|
}
|
||||||
window.selectedFiles.push(file);
|
window.selectedFiles.push(file);
|
||||||
|
|
||||||
|
// Track as in-progress draft at 0%
|
||||||
|
upsertResumableDraft(file, 0);
|
||||||
|
showResumableDraftBanner();
|
||||||
|
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
|
|
||||||
// Check if a wrapper already exists; if not, create one with a UL inside.
|
// Check if a wrapper already exists; if not, create one with a UL inside.
|
||||||
@@ -520,8 +695,40 @@ async function initResumableUpload() {
|
|||||||
|
|
||||||
resumableInstance.on("fileProgress", function (file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
const progress = file.progress(); // value between 0 and 1
|
const progress = file.progress(); // value between 0 and 1
|
||||||
const percent = Math.floor(progress * 100);
|
let percent = Math.floor(progress * 100);
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
|
||||||
|
// 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 (li && li.progressBar) {
|
||||||
if (percent < 99) {
|
if (percent < 99) {
|
||||||
li.progressBar.style.width = percent + "%";
|
li.progressBar.style.width = percent + "%";
|
||||||
@@ -553,6 +760,7 @@ async function initResumableUpload() {
|
|||||||
pauseResumeBtn.disabled = false;
|
pauseResumeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
upsertResumableDraft(file, percent);
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function (file, message) {
|
resumableInstance.on("fileSuccess", function (file, message) {
|
||||||
@@ -591,6 +799,9 @@ async function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
refreshFolderIcon(window.currentFolder);
|
refreshFolderIcon(window.currentFolder);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
// This file finished successfully, remove its draft record
|
||||||
|
clearResumableDraft(file.uniqueIdentifier);
|
||||||
|
showResumableDraftBanner();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -608,18 +819,22 @@ async function initResumableUpload() {
|
|||||||
pauseResumeBtn.disabled = false;
|
pauseResumeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
showToast("Error uploading file: " + file.fileName);
|
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.
|
// 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) {
|
if (!hasError) {
|
||||||
// All files succeeded—clear the file input and progress container after 5 seconds.
|
// All files succeeded—clear the file input and progress container after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) fileInput.value = "";
|
if (fileInput) fileInput.value = "";
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
progressContainer.innerHTML = "";
|
if (progressContainer) {
|
||||||
|
progressContainer.innerHTML = "";
|
||||||
|
}
|
||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
adjustFolderHelpExpansionClosed();
|
adjustFolderHelpExpansionClosed();
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||||
@@ -628,6 +843,15 @@ async function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) setDropAreaDefault();
|
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);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
showToast("Some files failed to upload. Please check the list.");
|
||||||
@@ -651,11 +875,34 @@ function submitFiles(allFiles) {
|
|||||||
const f = window.currentFolder || "root";
|
const f = window.currentFolder || "root";
|
||||||
try { return decodeURIComponent(f); } catch { return f; }
|
try { return decodeURIComponent(f); } catch { return f; }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
|
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 progressElements = {};
|
||||||
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
|
|
||||||
listItems.forEach(item => {
|
listItems.forEach(item => {
|
||||||
progressElements[item.dataset.uploadIndex] = item;
|
progressElements[item.dataset.uploadIndex] = item;
|
||||||
});
|
});
|
||||||
@@ -681,7 +928,7 @@ function submitFiles(allFiles) {
|
|||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
currentPercent = Math.round((e.loaded / e.total) * 100);
|
currentPercent = Math.round((e.loaded / e.total) * 100);
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
let speed = "";
|
let speed = "";
|
||||||
if (elapsed > 0) {
|
if (elapsed > 0) {
|
||||||
@@ -717,12 +964,12 @@ function submitFiles(allFiles) {
|
|||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
// real success
|
// real success
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerText = "Done";
|
li.progressBar.innerText = "Done";
|
||||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||||
@@ -731,39 +978,40 @@ function submitFiles(allFiles) {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// real failure
|
// real failure
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
allSucceeded = false;
|
allSucceeded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.isClipboard) {
|
if (file.isClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const pc = document.getElementById("uploadProgressContainer");
|
||||||
if (progressContainer) progressContainer.innerHTML = "";
|
if (pc) pc.innerHTML = "";
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fic = document.getElementById("fileInfoContainer");
|
||||||
if (fileInfoContainer) {
|
if (fic) {
|
||||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this upload as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
const succeededCount = uploadResults.filter(Boolean).length;
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
const failedCount = allFiles.length - succeededCount;
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("error", function () {
|
xhr.addEventListener("error", function () {
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = false;
|
uploadResults[file.uploadIndex] = false;
|
||||||
@@ -779,7 +1027,7 @@ if (finishedCount === allFiles.length) {
|
|||||||
|
|
||||||
xhr.addEventListener("abort", function () {
|
xhr.addEventListener("abort", function () {
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Aborted";
|
li.progressBar.innerText = "Aborted";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = false;
|
uploadResults[file.uploadIndex] = false;
|
||||||
@@ -809,38 +1057,42 @@ if (finishedCount === allFiles.length) {
|
|||||||
})
|
})
|
||||||
.map(s => s.trim().toLowerCase())
|
.map(s => s.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
let overallSuccess = true;
|
let overallSuccess = true;
|
||||||
let succeeded = 0;
|
let succeeded = 0;
|
||||||
|
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const clientFileName = file.name.trim().toLowerCase();
|
const clientFileName = file.name.trim().toLowerCase();
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
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";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
overallSuccess = false;
|
overallSuccess = false;
|
||||||
|
|
||||||
} else if (li) {
|
} else if (li) {
|
||||||
succeeded++;
|
succeeded++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
li.remove();
|
li.remove();
|
||||||
delete progressElements[file.uploadIndex];
|
delete progressElements[file.uploadIndex];
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const pc = document.getElementById("uploadProgressContainer");
|
||||||
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
|
if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
|
||||||
const fileInput = document.getElementById("file");
|
const fi = document.getElementById("file");
|
||||||
if (fileInput) fileInput.value = "";
|
if (fi) fi.value = "";
|
||||||
progressContainer.innerHTML = "";
|
pc.innerHTML = "";
|
||||||
adjustFolderHelpExpansionClosed();
|
adjustFolderHelpExpansionClosed();
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fic = document.getElementById("fileInfoContainer");
|
||||||
if (fileInfoContainer) {
|
if (fic) {
|
||||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
}
|
}
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) setDropAreaDefault();
|
if (dropArea) setDropAreaDefault();
|
||||||
|
window.selectedFiles = [];
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
@@ -850,7 +1102,7 @@ if (finishedCount === allFiles.length) {
|
|||||||
const failed = allFiles.length - succeeded;
|
const failed = allFiles.length - succeeded;
|
||||||
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
||||||
} else {
|
} else {
|
||||||
showToast(`${succeeded} file succeeded. Please check the list.`);
|
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -859,7 +1111,6 @@ if (finishedCount === allFiles.length) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -896,7 +1147,8 @@ function initUpload() {
|
|||||||
dropArea.addEventListener("drop", function (e) {
|
dropArea.addEventListener("drop", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
const dt = e.dataTransfer;
|
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||||
|
window.__pendingDropData = null;
|
||||||
if (dt.items && dt.items.length > 0) {
|
if (dt.items && dt.items.length > 0) {
|
||||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -917,17 +1169,23 @@ function initUpload() {
|
|||||||
fileInput.addEventListener("change", async function () {
|
fileInput.addEventListener("change", async function () {
|
||||||
const files = Array.from(fileInput.files || []);
|
const files = Array.from(fileInput.files || []);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
if (useResumable) {
|
if (useResumable) {
|
||||||
|
// New resumable batch: reset selectedFiles so the count is correct
|
||||||
|
window.selectedFiles = [];
|
||||||
|
|
||||||
// Ensure the lib/instance exists
|
// Ensure the lib/instance exists
|
||||||
if (!_resumableReady) await initResumableUpload();
|
if (!_resumableReady) await initResumableUpload();
|
||||||
if (resumableInstance) {
|
if (resumableInstance) {
|
||||||
for (const f of files) resumableInstance.addFile(f);
|
for (const f of files) {
|
||||||
|
resumableInstance.addFile(f);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If still not ready (load error), fall back to your XHR path
|
// If Resumable failed to load, fall back to XHR
|
||||||
processFiles(files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Non-resumable: normal XHR path, drag-and-drop etc.
|
||||||
processFiles(files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -936,27 +1194,40 @@ function initUpload() {
|
|||||||
if (uploadForm) {
|
if (uploadForm) {
|
||||||
uploadForm.addEventListener("submit", async function (e) {
|
uploadForm.addEventListener("submit", async function (e) {
|
||||||
e.preventDefault();
|
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) {
|
if (!files || !files.length) {
|
||||||
showToast("No files selected.");
|
showToast("No files selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resumable path (only for picked files, not folder uploads)
|
// If we have any files queued in Resumable, treat this as a resumable upload.
|
||||||
const first = files[0];
|
const hasResumableFiles =
|
||||||
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
|
useResumable &&
|
||||||
if (useResumable && !isFolderish) {
|
resumableInstance &&
|
||||||
|
Array.isArray(resumableInstance.files) &&
|
||||||
|
resumableInstance.files.length > 0;
|
||||||
|
|
||||||
|
if (hasResumableFiles) {
|
||||||
if (!_resumableReady) await initResumableUpload();
|
if (!_resumableReady) await initResumableUpload();
|
||||||
if (resumableInstance) {
|
if (resumableInstance) {
|
||||||
// ensure folder/token fresh
|
// Keep folder/token fresh
|
||||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||||
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
|
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
showToast("Resumable upload started...");
|
showToast("Resumable upload started...");
|
||||||
} else {
|
} else {
|
||||||
// fallback
|
// Hard fallback – should basically never happen
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// No resumable queue → drag-and-drop / paste / simple input → XHR path
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -965,6 +1236,7 @@ function initUpload() {
|
|||||||
if (useResumable) {
|
if (useResumable) {
|
||||||
initResumableUpload();
|
initResumableUpload();
|
||||||
}
|
}
|
||||||
|
showResumableDraftBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initUpload };
|
export { initUpload };
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v1.9.1';
|
window.APP_VERSION = 'v1.9.6';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
|||||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
|
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||||
|
|
||||||
class FolderController
|
class FolderController
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,10 @@ class FolderController
|
|||||||
return $headers;
|
return $headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
|
||||||
|
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
|
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
|
||||||
public static function stats(string $folder, string $user, array $perms): array
|
public static function stats(string $folder, string $user, array $perms): array
|
||||||
{
|
{
|
||||||
@@ -38,6 +43,161 @@ class FolderController
|
|||||||
return FolderModel::countVisible($folder, $user, $perms);
|
return FolderModel::countVisible($folder, $user, $perms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
|
||||||
|
public static function capabilities(string $folder, string $username): array
|
||||||
|
{
|
||||||
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
$perms = self::loadPermsFor($username);
|
||||||
|
|
||||||
|
$isAdmin = ACL::isAdmin($perms);
|
||||||
|
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
|
||||||
|
$readOnly = !empty($perms['readOnly']);
|
||||||
|
$disableUpload = !empty($perms['disableUpload']);
|
||||||
|
|
||||||
|
$isOwner = ACL::isOwner($username, $perms, $folder);
|
||||||
|
|
||||||
|
$inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);
|
||||||
|
|
||||||
|
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||||
|
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||||
|
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$canView = $canViewBase && $inScope;
|
||||||
|
|
||||||
|
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
|
||||||
|
$canCreate = $gCreateBase && !$readOnly && $inScope;
|
||||||
|
$canRename = $gRenameBase && !$readOnly && $inScope;
|
||||||
|
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||||
|
$canDeleteFile = $gDeleteBase && !$readOnly && $inScope;
|
||||||
|
|
||||||
|
$canDeleteFolder = !$readOnly && $inScope && (
|
||||||
|
$isAdmin ||
|
||||||
|
$isOwner ||
|
||||||
|
ACL::canManage($username, $perms, $folder) ||
|
||||||
|
$gDeleteBase // if your ACL::canDelete should also allow folder deletes
|
||||||
|
);
|
||||||
|
|
||||||
|
$canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
|
||||||
|
$canMoveIn = $canReceive;
|
||||||
|
|
||||||
|
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||||
|
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||||
|
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||||
|
|
||||||
|
$canShareEff = $canShareBase && $inScope;
|
||||||
|
$canShareFile = $gShareFile && $inScope;
|
||||||
|
$canShareFold = $gShareFolder && $inScope;
|
||||||
|
|
||||||
|
$isRoot = ($folder === 'root');
|
||||||
|
$canMoveFolder = false;
|
||||||
|
if ($isRoot) {
|
||||||
|
$canRename = false;
|
||||||
|
$canDelete = false;
|
||||||
|
$canShareFold = false;
|
||||||
|
} else {
|
||||||
|
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||||
|
&& !$readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = null;
|
||||||
|
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => $username,
|
||||||
|
'folder' => $folder,
|
||||||
|
'isAdmin' => $isAdmin,
|
||||||
|
'flags' => [
|
||||||
|
'folderOnly' => $folderOnly,
|
||||||
|
'readOnly' => $readOnly,
|
||||||
|
'disableUpload' => $disableUpload,
|
||||||
|
],
|
||||||
|
'owner' => $owner,
|
||||||
|
|
||||||
|
'canView' => $canView,
|
||||||
|
'canViewOwn' => $canViewOwn,
|
||||||
|
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canCreate' => $canCreate,
|
||||||
|
'canRename' => $canRename,
|
||||||
|
'canDelete' => $canDeleteFile,
|
||||||
|
'canDeleteFolder' => $canDeleteFolder,
|
||||||
|
|
||||||
|
'canMoveIn' => $canMoveIn,
|
||||||
|
'canMove' => $canMoveIn, // legacy alias
|
||||||
|
'canMoveFolder' => $canMoveFolder,
|
||||||
|
|
||||||
|
'canEdit' => $canEdit,
|
||||||
|
'canCopy' => $canCopy,
|
||||||
|
'canExtract' => $canExtract,
|
||||||
|
|
||||||
|
'canShare' => $canShareEff, // legacy umbrella
|
||||||
|
'canShareFile' => $canShareFile,
|
||||||
|
'canShareFolder' => $canShareFold,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------
|
||||||
|
Private helpers (caps)
|
||||||
|
----------------------------*/
|
||||||
|
private static 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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function boolFrom(array $a, string ...$keys): bool {
|
||||||
|
foreach ($keys as $k) if (!empty($a[$k])) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||||
|
$f = ACL::normalizeFolder($folder);
|
||||||
|
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
|
||||||
|
if ($isAdmin) return true;
|
||||||
|
if (!$folderOnly) return true; // normal users: global scope
|
||||||
|
|
||||||
|
$f = ACL::normalizeFolder($folder);
|
||||||
|
if ($f === 'root' || $f === '') {
|
||||||
|
return self::isOwnerOrAncestorOwner($u, $perms, $f);
|
||||||
|
}
|
||||||
|
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||||
|
return self::isOwnerOrAncestorOwner($u, $perms, $f);
|
||||||
|
}
|
||||||
|
|
||||||
private static function requireCsrf(): void
|
private static function requireCsrf(): void
|
||||||
{
|
{
|
||||||
self::ensureSession();
|
self::ensureSession();
|
||||||
@@ -1123,8 +1283,11 @@ class FolderController
|
|||||||
$map = FolderMeta::getMap();
|
$map = FolderMeta::getMap();
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($map as $folder => $hex) {
|
foreach ($map as $folder => $hex) {
|
||||||
$folder = FolderMeta::normalizeFolder($folder);
|
$folder = FolderMeta::normalizeFolder((string)$folder);
|
||||||
if (ACL::canRead($user, $perms, $folder)) $out[$folder] = $hex;
|
if ($folder === 'root') continue; // don’t bother exposing root
|
||||||
|
if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
|
||||||
|
$out[$folder] = $hex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
echo json_encode($out, JSON_UNESCAPED_SLASHES);
|
echo json_encode($out, JSON_UNESCAPED_SLASHES);
|
||||||
}
|
}
|
||||||
@@ -1153,21 +1316,29 @@ class FolderController
|
|||||||
|
|
||||||
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
|
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
|
||||||
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
|
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
|
||||||
$color = isset($body['color']) ? (string)$body['color'] : '';
|
$raw = array_key_exists('color', $body) ? (string)$body['color'] : '';
|
||||||
|
|
||||||
// Treat “customize color” as rename-level capability (your convention)
|
if ($folder === 'root') {
|
||||||
if (!ACL::canRename($user, $perms, $folder)) {
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Cannot set color on root']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// >>> Require canEdit (not canRename) <<<
|
||||||
|
if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Forbidden']);
|
echo json_encode(['error' => 'Forbidden']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$res = FolderMeta::setColor($folder, $color === '' ? null : $color);
|
// empty string clears; non-empty must be valid #RGB or #RRGGBB
|
||||||
|
$hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
|
||||||
|
$res = FolderMeta::setColor($folder, $hex);
|
||||||
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
|
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => $e->getMessage()]);
|
echo json_encode(['error' => 'Invalid color']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ class MediaController
|
|||||||
$f = trim((string)$f);
|
$f = trim((string)$f);
|
||||||
return ($f==='' || strtolower($f)==='root') ? 'root' : $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 {
|
private function validFile($f): bool {
|
||||||
$f = basename((string)$f);
|
$f = basename((string)$f);
|
||||||
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
|
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
|
||||||
@@ -56,6 +53,24 @@ class MediaController
|
|||||||
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
|
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 */
|
/** POST /api/media/updateProgress.php */
|
||||||
public function updateProgress(): void {
|
public function updateProgress(): void {
|
||||||
$this->jsonStart();
|
$this->jsonStart();
|
||||||
@@ -67,15 +82,15 @@ class MediaController
|
|||||||
$d = $this->readJson();
|
$d = $this->readJson();
|
||||||
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
|
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
|
||||||
$file = (string)($d['file'] ?? '');
|
$file = (string)($d['file'] ?? '');
|
||||||
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
|
$seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
|
||||||
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
|
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
|
||||||
$completed = isset($d['completed']) ? (bool)$d['completed'] : 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)) {
|
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
$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) {
|
if ($clear) {
|
||||||
$ok = MediaModel::clearProgress($u, $folder, $file);
|
$ok = MediaModel::clearProgress($u, $folder, $file);
|
||||||
@@ -102,7 +117,7 @@ class MediaController
|
|||||||
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
$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);
|
$row = MediaModel::getProgress($u, $folder, $file);
|
||||||
$this->out(['state'=>$row]);
|
$this->out(['state'=>$row]);
|
||||||
@@ -123,7 +138,12 @@ class MediaController
|
|||||||
if (!$this->validFolder($folder)) {
|
if (!$this->validFolder($folder)) {
|
||||||
$this->out(['error'=>'Invalid folder'], 400); return;
|
$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);
|
$map = MediaModel::getFolderMap($u, $folder);
|
||||||
$this->out(['map'=>$map]);
|
$this->out(['map'=>$map]);
|
||||||
|
|||||||
@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
|
|||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
||||||
|
|
||||||
class UploadController {
|
class UploadController
|
||||||
|
{
|
||||||
public function handleUpload(): void {
|
public function handleUpload(): void
|
||||||
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// ---- 1) CSRF (header or form field) ----
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
|
$requestParams = ($method === 'GET') ? $_GET : $_POST;
|
||||||
$received = '';
|
|
||||||
if (!empty($headersArr['x-csrf-token'])) {
|
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
|
||||||
$received = trim($headersArr['x-csrf-token']);
|
$isResumableTest =
|
||||||
} elseif (!empty($_POST['csrf_token'])) {
|
($method === 'GET'
|
||||||
$received = trim($_POST['csrf_token']);
|
&& isset($requestParams['resumableChunkNumber'])
|
||||||
} elseif (!empty($_POST['upload_token'])) {
|
&& isset($requestParams['resumableIdentifier']));
|
||||||
// legacy alias
|
|
||||||
$received = trim($_POST['upload_token']);
|
// ---- 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 ----
|
// ---- 2) Auth + account-level flags ----
|
||||||
if (empty($_SESSION['authenticated'])) {
|
if (empty($_SESSION['authenticated'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = (string)($_SESSION['username'] ?? '');
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
$userPerms = loadUserPermissions($username) ?: [];
|
$userPerms = loadUserPermissions($username) ?: [];
|
||||||
$isAdmin = ACL::isAdmin($userPerms);
|
$isAdmin = ACL::isAdmin($userPerms);
|
||||||
|
|
||||||
// Admins should never be blocked by account-level "disableUpload"
|
// Admins should never be blocked by account-level "disableUpload"
|
||||||
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
|
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Upload disabled for this user.']);
|
echo json_encode(['error' => 'Upload disabled for this user.']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 3) Folder-level WRITE permission (ACL) ----
|
// ---- 3) Folder-level WRITE permission (ACL) ----
|
||||||
// Always require client to send the folder; fall back to GET if needed.
|
// Prefer the unified param array, fall back to GET only if needed.
|
||||||
$folderParam = isset($_POST['folder'])
|
$folderParam = isset($requestParams['folder'])
|
||||||
? (string)$_POST['folder']
|
? (string)$requestParams['folder']
|
||||||
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
|
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
|
||||||
|
|
||||||
// Decode %xx (e.g., "test%20folder") then normalize
|
// Decode %xx (e.g., "test%20folder") then normalize
|
||||||
$folderParam = rawurldecode($folderParam);
|
$folderParam = rawurldecode($folderParam);
|
||||||
$targetFolder = ACL::normalizeFolder($folderParam);
|
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||||
|
|
||||||
// Admins bypass folder canWrite checks
|
// Admins bypass folder canWrite checks
|
||||||
$username = (string)($_SESSION['username'] ?? '');
|
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
||||||
$userPerms = loadUserPermissions($username) ?: [];
|
http_response_code(403);
|
||||||
$isAdmin = ACL::isAdmin($userPerms);
|
echo json_encode([
|
||||||
|
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
// ---- 4) Delegate to model (force the sanitized folder) ----
|
||||||
http_response_code(403);
|
$requestParams['folder'] = $targetFolder;
|
||||||
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
// Keep legacy behavior for anything still reading $_POST directly
|
||||||
return;
|
$_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) ----
|
public function removeChunks(): void
|
||||||
$_POST['folder'] = $targetFolder; // in case model reads superglobal
|
{
|
||||||
$post = $_POST;
|
header('Content-Type: application/json');
|
||||||
$post['folder'] = $targetFolder;
|
|
||||||
|
|
||||||
$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($_POST['folder'])) {
|
||||||
if (isset($result['error'])) {
|
http_response_code(400);
|
||||||
http_response_code(400);
|
echo json_encode(['error' => 'No folder specified']);
|
||||||
echo json_encode($result);
|
return;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
87
src/lib/FS.php
Normal file
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||||
|
|
||||||
class FolderModel
|
class FolderModel
|
||||||
{
|
{
|
||||||
@@ -10,44 +11,228 @@ class FolderModel
|
|||||||
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
public static function countVisible(string $folder, string $user, array $perms): array
|
public static function countVisible(string $folder, string $user, array $perms): array
|
||||||
{
|
{
|
||||||
// Normalize
|
$folder = ACL::normalizeFolder($folder);
|
||||||
$folder = ACL::normalizeFolder($folder);
|
|
||||||
|
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
||||||
// ACL gate: if you can’t read, report empty (no leaks)
|
$canViewFolder = ACL::isAdmin($perms)
|
||||||
if (!$user || !ACL::canRead($user, $perms, $folder)) {
|
|| ACL::canRead($user, $perms, $folder)
|
||||||
return ['folders' => 0, 'files' => 0];
|
|| ACL::canReadOwn($user, $perms, $folder);
|
||||||
}
|
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
|
||||||
|
|
||||||
// Resolve paths under UPLOAD_DIR
|
$base = realpath((string)UPLOAD_DIR);
|
||||||
$root = rtrim((string)UPLOAD_DIR, '/\\');
|
if ($base === false) return ['folders' => 0, 'files' => 0];
|
||||||
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
|
|
||||||
|
// Resolve target dir + ACL-relative prefix
|
||||||
$realRoot = @realpath($root);
|
if ($folder === 'root') {
|
||||||
$realPath = @realpath($path);
|
$dir = $base;
|
||||||
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
|
$relPrefix = '';
|
||||||
return ['folders' => 0, 'files' => 0];
|
} else {
|
||||||
}
|
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||||
|
foreach ($parts as $seg) {
|
||||||
// Count quickly, skipping UI-internal dirs
|
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
|
||||||
$folders = 0; $files = 0;
|
}
|
||||||
try {
|
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
foreach (new DirectoryIterator($realPath) as $f) {
|
$dir = self::safeReal($base, $guess);
|
||||||
if ($f->isDot()) continue;
|
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
|
||||||
$name = $f->getFilename();
|
$relPrefix = implode('/', $parts);
|
||||||
if ($name === 'trash' || $name === 'profile_pics') continue;
|
}
|
||||||
|
|
||||||
if ($f->isDir()) $folders++; else $files++;
|
// Ignore lists (expandable)
|
||||||
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
|
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||||
}
|
$SKIP = ['trash', 'profile_pics'];
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Stay quiet + safe
|
$entries = @scandir($dir);
|
||||||
$folders = 0; $files = 0;
|
if ($entries === false) return ['folders' => 0, 'files' => 0];
|
||||||
}
|
|
||||||
|
$hasChildFolder = false;
|
||||||
return ['folders' => $folders, 'files' => $files];
|
$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. */
|
/** Load the folder → owner map. */
|
||||||
public static function getFolderOwners(): array
|
public static function getFolderOwners(): array
|
||||||
@@ -213,40 +398,42 @@ class FolderModel
|
|||||||
// -------- Normalize incoming values (use ONLY the parameters) --------
|
// -------- Normalize incoming values (use ONLY the parameters) --------
|
||||||
$folderName = trim((string)$folderName);
|
$folderName = trim((string)$folderName);
|
||||||
$parentIn = trim((string)$parent);
|
$parentIn = trim((string)$parent);
|
||||||
|
|
||||||
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
|
// 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".
|
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
|
||||||
$normalized = ACL::normalizeFolder($folderName);
|
$normalized = ACL::normalizeFolder($folderName);
|
||||||
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
|
if (
|
||||||
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
|
$normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||||
|
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)
|
||||||
|
) {
|
||||||
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
||||||
$folderName = basename($normalized);
|
$folderName = basename($normalized);
|
||||||
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
||||||
}
|
}
|
||||||
|
|
||||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||||
$folderName = trim($folderName);
|
$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
|
// ACL key for new folder
|
||||||
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
||||||
|
|
||||||
// -------- Compose filesystem paths --------
|
// -------- Compose filesystem paths --------
|
||||||
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
||||||
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
||||||
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
||||||
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
||||||
|
|
||||||
// -------- Exists / sanity checks --------
|
// -------- Exists / sanity checks --------
|
||||||
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
|
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($newAbs)) return ['success' => false, 'error' => 'Folder already exists'];
|
||||||
|
|
||||||
// -------- Create directory --------
|
// -------- Create directory --------
|
||||||
if (!@mkdir($newAbs, 0775, true)) {
|
if (!@mkdir($newAbs, 0775, true)) {
|
||||||
$err = error_get_last();
|
$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 --------
|
// -------- Seed ACL --------
|
||||||
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
||||||
try {
|
try {
|
||||||
@@ -265,9 +452,9 @@ class FolderModel
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Roll back FS if ACL seeding fails
|
// Roll back FS if ACL seeding fails
|
||||||
@rmdir($newAbs);
|
@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];
|
return ['success' => true, 'folder' => $newKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +505,7 @@ class FolderModel
|
|||||||
|
|
||||||
// Validate names (per-segment)
|
// Validate names (per-segment)
|
||||||
foreach ([$oldFolder, $newFolder] as $f) {
|
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)."];
|
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
|
||||||
foreach ($parts as $seg) {
|
foreach ($parts as $seg) {
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||||
@@ -333,7 +520,7 @@ class FolderModel
|
|||||||
$base = realpath(UPLOAD_DIR);
|
$base = realpath(UPLOAD_DIR);
|
||||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
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);
|
$newRel = implode('/', $newParts);
|
||||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||||
|
|
||||||
@@ -508,7 +695,7 @@ class FolderModel
|
|||||||
return [
|
return [
|
||||||
"record" => $record,
|
"record" => $record,
|
||||||
"folder" => $relative,
|
"folder" => $relative,
|
||||||
"realFolderPath"=> $realFolderPath,
|
"realFolderPath" => $realFolderPath,
|
||||||
"files" => $filesOnPage,
|
"files" => $filesOnPage,
|
||||||
"currentPage" => $currentPage,
|
"currentPage" => $currentPage,
|
||||||
"totalPages" => $totalPages
|
"totalPages" => $totalPages
|
||||||
@@ -532,7 +719,7 @@ class FolderModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$expires = time() + max(1, $expirationSeconds);
|
$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";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
$links = file_exists($shareFile)
|
$links = file_exists($shareFile)
|
||||||
@@ -560,7 +747,7 @@ class FolderModel
|
|||||||
|
|
||||||
// Build URL
|
// Build URL
|
||||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||||
$scheme = $https ? 'https' : 'http';
|
$scheme = $https ? 'https' : 'http';
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||||
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||||
@@ -587,7 +774,7 @@ class FolderModel
|
|||||||
return ["error" => "This share link has expired."];
|
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)) {
|
if ($err || !is_dir($realFolderPath)) {
|
||||||
return ["error" => "Shared folder not found."];
|
return ["error" => "Shared folder not found."];
|
||||||
}
|
}
|
||||||
@@ -615,8 +802,26 @@ class FolderModel
|
|||||||
// Max size & allowed extensions (mirror FileModel’s common types)
|
// Max size & allowed extensions (mirror FileModel’s common types)
|
||||||
$maxSize = 50 * 1024 * 1024; // 50 MB
|
$maxSize = 50 * 1024 * 1024; // 50 MB
|
||||||
$allowedExtensions = [
|
$allowedExtensions = [
|
||||||
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
|
'jpg',
|
||||||
'mp4','webm','mp3','mkv','csv','json','xml','md'
|
'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";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
@@ -655,7 +860,7 @@ class FolderModel
|
|||||||
|
|
||||||
// New safe filename
|
// New safe filename
|
||||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||||
$newFilename= uniqid('', true) . "_" . $safeBase;
|
$newFilename = uniqid('', true) . "_" . $safeBase;
|
||||||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||||||
|
|
||||||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||||||
@@ -697,4 +902,4 @@ class FolderModel
|
|||||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class UploadModel {
|
class UploadModel
|
||||||
|
{
|
||||||
private static function sanitizeFolder(string $folder): string {
|
private static function sanitizeFolder(string $folder): string
|
||||||
|
{
|
||||||
// decode "%20", normalise slashes & trim via ACL helper
|
// decode "%20", normalise slashes & trim via ACL helper
|
||||||
$f = ACL::normalizeFolder(rawurldecode($folder));
|
$f = ACL::normalizeFolder(rawurldecode($folder));
|
||||||
|
|
||||||
// model uses '' to represent root
|
// model uses '' to represent root
|
||||||
if ($f === 'root') return '';
|
if ($f === 'root') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// forbid dot segments / empty parts
|
// forbid dot segments / empty parts
|
||||||
foreach (explode('/', $f) as $seg) {
|
foreach (explode('/', $f) as $seg) {
|
||||||
@@ -28,9 +31,13 @@ class UploadModel {
|
|||||||
return $f; // safe, normalised, with spaces allowed
|
return $f; // safe, normalised, with spaces allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function handleUpload(array $post, array $files): array {
|
public static function handleUpload(array $post, array $files): array
|
||||||
// --- GET resumable test (make folder handling consistent)
|
{
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
|
// --- GET resumable test (make folder handling consistent) ---
|
||||||
|
if (
|
||||||
|
(($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
|
||||||
|
&& isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
|
||||||
|
) {
|
||||||
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
|
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
|
||||||
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
||||||
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
@@ -38,15 +45,16 @@ class UploadModel {
|
|||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folderSan !== '') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
||||||
$chunkFile = $tempDir . $chunkNumber;
|
$chunkFile = $tempDir . $chunkNumber;
|
||||||
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
|
|
||||||
|
return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CHUNKED ---
|
// --- CHUNKED (Resumable.js POST uploads) ---
|
||||||
if (isset($post['resumableChunkNumber'])) {
|
if (isset($post['resumableChunkNumber'])) {
|
||||||
$chunkNumber = (int)$post['resumableChunkNumber'];
|
$chunkNumber = (int)$post['resumableChunkNumber'];
|
||||||
$totalChunks = (int)$post['resumableTotalChunks'];
|
$totalChunks = (int)$post['resumableTotalChunks'];
|
||||||
@@ -54,109 +62,126 @@ class UploadModel {
|
|||||||
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
|
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
|
||||||
|
|
||||||
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
|
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
|
||||||
return ["error" => "Invalid file name: $resumableFilename"];
|
return ['error' => "Invalid file name: $resumableFilename"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
|
|
||||||
if (empty($files['file']) || !isset($files['file']['name'])) {
|
if (empty($files['file']) || !isset($files['file']['name'])) {
|
||||||
return ["error" => "No files received"];
|
return ['error' => 'No files received'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folderSan !== '') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ['error' => 'Failed to create upload directory'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
||||||
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
|
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;
|
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||||
if ($chunkErr !== UPLOAD_ERR_OK) {
|
if ($chunkErr !== UPLOAD_ERR_OK) {
|
||||||
return ["error" => "Upload error on chunk $chunkNumber"];
|
return ['error' => "Upload error on chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$chunkFile = $tempDir . $chunkNumber;
|
$chunkFile = $tempDir . $chunkNumber;
|
||||||
$tmpName = $files['file']['tmp_name'] ?? null;
|
$tmpName = $files['file']['tmp_name'] ?? null;
|
||||||
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
|
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
|
||||||
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
return ['error' => "Failed to move uploaded chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// all chunks present?
|
// All chunks present?
|
||||||
for ($i = 1; $i <= $totalChunks; $i++) {
|
for ($i = 1; $i <= $totalChunks; $i++) {
|
||||||
if (!file_exists($tempDir . $i)) {
|
if (!file_exists($tempDir . $i)) {
|
||||||
return ["status" => "chunk uploaded"];
|
return ['status' => 'chunk uploaded'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge
|
// Merge
|
||||||
$targetPath = $baseUploadDir . $resumableFilename;
|
$targetPath = $baseUploadDir . $resumableFilename;
|
||||||
if (!$out = fopen($targetPath, "wb")) {
|
if (!$out = fopen($targetPath, 'wb')) {
|
||||||
return ["error" => "Failed to open target file for writing"];
|
return ['error' => 'Failed to open target file for writing'];
|
||||||
}
|
}
|
||||||
for ($i = 1; $i <= $totalChunks; $i++) {
|
for ($i = 1; $i <= $totalChunks; $i++) {
|
||||||
$chunkPath = $tempDir . $i;
|
$chunkPath = $tempDir . $i;
|
||||||
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
|
if (!file_exists($chunkPath)) {
|
||||||
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
|
fclose($out);
|
||||||
while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
|
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($in);
|
||||||
}
|
}
|
||||||
fclose($out);
|
fclose($out);
|
||||||
|
|
||||||
// metadata
|
// Metadata
|
||||||
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
$uploader = $_SESSION['username'] ?? "Unknown";
|
$uploader = $_SESSION['username'] ?? 'Unknown';
|
||||||
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$collection = file_exists($metadataFile)
|
||||||
if (!is_array($collection)) $collection = [];
|
? json_decode(file_get_contents($metadataFile), true)
|
||||||
|
: [];
|
||||||
|
if (!is_array($collection)) {
|
||||||
|
$collection = [];
|
||||||
|
}
|
||||||
if (!isset($collection[$resumableFilename])) {
|
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));
|
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup temp
|
// Cleanup temp
|
||||||
self::rrmdir($tempDir);
|
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'));
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folderSan !== '') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ['error' => 'Failed to create upload directory'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
$metadataCollection = [];
|
$metadataCollection = [];
|
||||||
$metadataChanged = [];
|
$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) {
|
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)));
|
$safeFileName = trim(urldecode(basename($fileName)));
|
||||||
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
||||||
return ["error" => "Invalid file name: " . $fileName];
|
return ['error' => 'Invalid file name: ' . $fileName];
|
||||||
}
|
}
|
||||||
|
|
||||||
$relativePath = '';
|
$relativePath = '';
|
||||||
if (isset($post['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;
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
@@ -164,34 +189,41 @@ class UploadModel {
|
|||||||
$subDir = dirname($relativePath);
|
$subDir = dirname($relativePath);
|
||||||
if ($subDir !== '.' && $subDir !== '') {
|
if ($subDir !== '.' && $subDir !== '') {
|
||||||
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
$safeFileName = basename($relativePath);
|
$safeFileName = basename($relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
|
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;
|
$targetPath = $uploadDir . $safeFileName;
|
||||||
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
|
||||||
return ["error" => "Error uploading file"];
|
return ['error' => 'Error uploading file'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
|
|
||||||
if (!isset($metadataCollection[$metadataKey])) {
|
if (!isset($metadataCollection[$metadataKey])) {
|
||||||
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$metadataCollection[$metadataKey] = file_exists($metadataFile)
|
||||||
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
|
? json_decode(file_get_contents($metadataFile), true)
|
||||||
|
: [];
|
||||||
|
if (!is_array($metadataCollection[$metadataKey])) {
|
||||||
|
$metadataCollection[$metadataKey] = [];
|
||||||
|
}
|
||||||
$metadataChanged[$metadataKey] = false;
|
$metadataChanged[$metadataKey] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
|
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
$uploader = $_SESSION['username'] ?? "Unknown";
|
$uploader = $_SESSION['username'] ?? 'Unknown';
|
||||||
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
|
$metadataCollection[$metadataKey][$safeFileName] = [
|
||||||
|
'uploaded' => $uploadedDate,
|
||||||
|
'uploader' => $uploader,
|
||||||
|
];
|
||||||
$metadataChanged[$metadataKey] = true;
|
$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.
|
* Recursively removes a directory and its contents.
|
||||||
*
|
*
|
||||||
* @param string $dir The directory to remove.
|
* @param string $dir The directory to remove.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function rrmdir(string $dir): void {
|
private static function rrmdir(string $dir): void
|
||||||
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,7 +263,7 @@ class UploadModel {
|
|||||||
}
|
}
|
||||||
rmdir($dir);
|
rmdir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the temporary chunk directory for resumable uploads.
|
* Removes the temporary chunk directory for resumable uploads.
|
||||||
*
|
*
|
||||||
@@ -240,25 +272,26 @@ class UploadModel {
|
|||||||
* @param string $folder The folder name provided (URL-decoded).
|
* @param string $folder The folder name provided (URL-decoded).
|
||||||
* @return array Returns a status array indicating success or error.
|
* @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);
|
$folder = urldecode($folder);
|
||||||
// The folder name should exactly match the "resumable_" pattern.
|
// The folder name should exactly match the "resumable_" pattern.
|
||||||
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
|
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
|
||||||
if (!preg_match($regex, $folder)) {
|
if (!preg_match($regex, $folder)) {
|
||||||
return ["error" => "Invalid folder name"];
|
return ['error' => 'Invalid folder name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||||
if (!is_dir($tempDir)) {
|
if (!is_dir($tempDir)) {
|
||||||
return ["success" => true, "message" => "Temporary folder already removed."];
|
return ['success' => true, 'message' => 'Temporary folder already removed.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
self::rrmdir($tempDir);
|
self::rrmdir($tempDir);
|
||||||
|
|
||||||
if (!is_dir($tempDir)) {
|
if (!is_dir($tempDir)) {
|
||||||
return ["success" => true, "message" => "Temporary folder removed."];
|
return ['success' => true, 'message' => 'Temporary folder removed.'];
|
||||||
} else {
|
|
||||||
return ["error" => "Failed to remove temporary folder."];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ['error' => 'Failed to remove temporary folder.'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user