Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 |
215
.github/workflows/release-on-version.yml
vendored
@@ -6,160 +6,79 @@ on:
|
|||||||
branches: ["master"]
|
branches: ["master"]
|
||||||
paths:
|
paths:
|
||||||
- public/js/version.js
|
- public/js/version.js
|
||||||
workflow_run:
|
|
||||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
|
||||||
types: [completed]
|
|
||||||
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 2 minutes
|
|
||||||
run: sleep 120
|
|
||||||
|
|
||||||
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: |
|
||||||
|
|||||||
295
CHANGELOG.md
@@ -1,5 +1,300 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/11/2025 (v1.9.3)
|
||||||
|
|
||||||
|
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
|
||||||
|
|
||||||
|
- UI / Icons
|
||||||
|
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
|
||||||
|
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
|
||||||
|
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
|
||||||
|
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
|
||||||
|
|
||||||
|
- Folder color & non-empty detection
|
||||||
|
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
|
||||||
|
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
|
||||||
|
|
||||||
|
- FileList strip
|
||||||
|
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
|
||||||
|
|
||||||
|
- Exports & helpers
|
||||||
|
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
|
||||||
|
|
||||||
|
- AppCore
|
||||||
|
- Update file upload DnD relay hook to `#fileList` (id rename).
|
||||||
|
|
||||||
|
- CSS tweaks
|
||||||
|
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
|
||||||
|
|
||||||
|
- CI/CD (release)
|
||||||
|
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
|
||||||
|
|
||||||
|
- Changelog highlights
|
||||||
|
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
|
||||||
|
- Real-time folder color propagation between views.
|
||||||
|
- Folder strip switched to SVG tiles with better layout + context actions.
|
||||||
|
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/10/2025 (v1.9.2)
|
||||||
|
|
||||||
|
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||||
|
|
||||||
|
- New “Upload file(s)” action in Create menu:
|
||||||
|
- Adds `<li id="uploadOption">` to the dropdown.
|
||||||
|
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||||
|
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||||
|
|
||||||
|
- Drag & Drop from file list → Upload:
|
||||||
|
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||||
|
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||||
|
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||||
|
|
||||||
|
- Synthetic drop fallback:
|
||||||
|
- Introduces window.__pendingDropData (cleared after use).
|
||||||
|
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||||
|
|
||||||
|
- Implementation details:
|
||||||
|
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||||
|
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||||
|
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||||
|
|
||||||
|
- UX/Safety:
|
||||||
|
- Defensive checks if modal/card isn’t present.
|
||||||
|
- No backend/API changes; CSRF/auth unchanged.
|
||||||
|
|
||||||
|
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.1)
|
||||||
|
|
||||||
|
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||||
|
|
||||||
|
### Highlights v1.9.1
|
||||||
|
|
||||||
|
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
|
||||||
|
- 📄 Folder icons auto-refresh when contents change (no full page reload).
|
||||||
|
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
|
||||||
|
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
|
||||||
|
|
||||||
|
- feat(colors): add per-folder color customization
|
||||||
|
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
|
||||||
|
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
|
||||||
|
- Read endpoint filters map by ACL::canRead before returning to the user.
|
||||||
|
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
|
||||||
|
|
||||||
|
- feat(ui): color-picker modal with live SVG folder preview
|
||||||
|
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
|
||||||
|
|
||||||
|
- feat(controls): “Color folder” button in Folder Management card
|
||||||
|
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
|
||||||
|
|
||||||
|
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
|
||||||
|
|
||||||
|
- ux(tree): make expansion state more predictable across refreshes
|
||||||
|
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
|
||||||
|
|
||||||
|
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
|
||||||
|
|
||||||
|
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.0)
|
||||||
|
|
||||||
|
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||||
|
|
||||||
|
feat(ui): modern folder tree
|
||||||
|
|
||||||
|
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
|
||||||
|
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
|
||||||
|
- Breadcrumb tweaks (› separators), hover/selected polish
|
||||||
|
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
|
||||||
|
|
||||||
|
feat(api): add /api/folder/isEmpty.php via controller/model
|
||||||
|
|
||||||
|
- public/api/folder/isEmpty.php delegates to FolderController::stats()
|
||||||
|
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
|
||||||
|
- Releases PHP session lock early to avoid parallel-request pileups
|
||||||
|
|
||||||
|
perf: cap concurrent “isEmpty” requests + timeouts
|
||||||
|
|
||||||
|
- Small concurrency limiter + fetch timeouts
|
||||||
|
- In-memory result & inflight caches for fewer network hits
|
||||||
|
|
||||||
|
fix(state): preserve user expand/collapse choices
|
||||||
|
|
||||||
|
- Respect saved folderTreeState; don’t auto-expand unopened nodes
|
||||||
|
- Only show ancestors for visibility when navigating (no unwanted persists)
|
||||||
|
|
||||||
|
security: tighten .htaccess while enabling WebDAV
|
||||||
|
|
||||||
|
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
|
||||||
|
- AcceptPathInfo On; keep path-aware dotfile denial
|
||||||
|
|
||||||
|
refactor: move count logic to model; thin controller action
|
||||||
|
|
||||||
|
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
|
||||||
|
|
||||||
|
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.13)
|
||||||
|
|
||||||
|
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
|
||||||
|
|
||||||
|
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
|
||||||
|
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
|
||||||
|
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
|
||||||
|
- header dock: show dock whenever icons exist (and on collapse); hide when empty
|
||||||
|
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
|
||||||
|
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
|
||||||
|
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
|
||||||
|
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.12)
|
||||||
|
|
||||||
|
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||||
|
|
||||||
|
- auth (public/js/main.js)
|
||||||
|
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||||
|
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||||
|
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||||
|
- Minor cleanup (SW register catch spacing).
|
||||||
|
|
||||||
|
- drag & drop (public/js/dragAndDrop.js)
|
||||||
|
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||||
|
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||||
|
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||||
|
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||||
|
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||||
|
|
||||||
|
- styles (public/css/styles.css)
|
||||||
|
- Header drop zone fills remaining space and right-aligns its icons.
|
||||||
|
|
||||||
|
UX:
|
||||||
|
|
||||||
|
- OIDC button reliably appears when form/basic are disabled.
|
||||||
|
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||||
|
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.11)
|
||||||
|
|
||||||
|
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||||
|
|
||||||
|
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||||
|
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||||
|
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||||
|
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.10)
|
||||||
|
|
||||||
|
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||||
|
|
||||||
|
UI/UX — Media modal
|
||||||
|
|
||||||
|
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||||
|
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||||
|
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||||
|
|
||||||
|
Video/Image controls
|
||||||
|
|
||||||
|
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||||
|
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||||
|
|
||||||
|
Progress badges (list & modal)
|
||||||
|
|
||||||
|
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||||
|
|
||||||
|
Drag & drop
|
||||||
|
|
||||||
|
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||||
|
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||||
|
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||||
|
|
||||||
|
Editor & ONLYOFFICE
|
||||||
|
|
||||||
|
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||||
|
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||||
|
|
||||||
|
Assets & polish
|
||||||
|
|
||||||
|
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||||
|
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||||
|
|
||||||
|
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.9)
|
||||||
|
|
||||||
|
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||||
|
|
||||||
|
- adminPanel.js:
|
||||||
|
- Masked inputs without a saved value now start with data-replace="1".
|
||||||
|
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.8)
|
||||||
|
|
||||||
|
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||||
|
|
||||||
|
**Summary**
|
||||||
|
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||||
|
|
||||||
|
**Backend** changes:
|
||||||
|
|
||||||
|
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot downloads.
|
||||||
|
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
|
||||||
|
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
|
||||||
|
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
|
||||||
|
|
||||||
|
New **Worker**:
|
||||||
|
|
||||||
|
- New `src/cli/zip_worker.php` builds the archive in the background.
|
||||||
|
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the per‑token JSON.
|
||||||
|
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
|
||||||
|
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
|
||||||
|
|
||||||
|
New **API/Status Payload**:
|
||||||
|
|
||||||
|
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
|
||||||
|
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
|
||||||
|
|
||||||
|
**Frontend (UX)** changes:
|
||||||
|
|
||||||
|
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
|
||||||
|
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
|
||||||
|
- Shows determinate **0–98%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
|
||||||
|
- Modal closes just before download; UI resets for the next operation.
|
||||||
|
|
||||||
|
Added **CSS**:
|
||||||
|
|
||||||
|
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
|
||||||
|
|
||||||
|
**Why this closes #60**?
|
||||||
|
|
||||||
|
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
|
||||||
|
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
|
||||||
|
- Users get continuous, truthful feedback for large multi‑GB archives.
|
||||||
|
|
||||||
|
Additional **Notes**
|
||||||
|
|
||||||
|
- Download tokens are **one‑shot** and are deleted after the GET completes.
|
||||||
|
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/5/2025 (v1.8.7)
|
## Changes 11/5/2025 (v1.8.7)
|
||||||
|
|
||||||
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
||||||
|
|||||||
33
README.md
@@ -21,16 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
|
|||||||
|
|
||||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
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:**
|
||||||
|
|
||||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||||
|
|
||||||
**Dark mode:**
|

|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -405,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.
|
||||||
@@ -446,18 +430,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
|||||||
|
|
||||||
### ONLYOFFICE integration
|
### ONLYOFFICE integration
|
||||||
|
|
||||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
|
||||||
|
|
||||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||||
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
|
|
||||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||||
|
|
||||||
#### Security / CSP
|
|
||||||
|
|
||||||
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
|
|
||||||
|
|
||||||
### PHP Libraries
|
### PHP Libraries
|
||||||
|
|
||||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||||
@@ -479,7 +456,7 @@ If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
|||||||
define('ONLYOFFICE_DEBUG', true);
|
define('ONLYOFFICE_DEBUG', true);
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||||
|
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||||
|
}
|
||||||
|
|
||||||
// Encryption helpers
|
// Encryption helpers
|
||||||
function encryptData($data, $encryptionKey)
|
function encryptData($data, $encryptionKey)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# --------------------------------
|
# --------------------------------
|
||||||
# FileRise portable .htaccess
|
# FileRise portable .htaccess
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
Options -Indexes
|
Options -Indexes -Multiviews
|
||||||
DirectoryIndex index.html
|
DirectoryIndex index.html
|
||||||
|
|
||||||
|
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||||
|
AcceptPathInfo On
|
||||||
|
|
||||||
|
# ---------------- Security: dotfiles ----------------
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known
|
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||||
<FilesMatch "^\.(?!well-known(?:/|$))">
|
<FilesMatch "^\..*">
|
||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
</IfModule>
|
</IfModule>
|
||||||
@@ -15,15 +19,28 @@ DirectoryIndex index.html
|
|||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
# Never redirect local/dev hosts
|
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
|
||||||
RewriteRule ^ - [L]
|
|
||||||
|
|
||||||
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
|
|
||||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||||
RewriteRule - - [L]
|
RewriteRule - - [L]
|
||||||
|
|
||||||
# HTTPS redirect (enable ONE of these, comment the other)
|
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||||
|
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||||
|
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||||
|
|
||||||
|
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||||
|
# - allow /api/*.php (API endpoints)
|
||||||
|
# - allow /api.php (ReDoc/spec page)
|
||||||
|
# - allow /webdav.php (SabreDAV front)
|
||||||
|
RewriteCond %{REQUEST_URI} !^/api/ [NC]
|
||||||
|
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
|
||||||
|
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
|
||||||
|
RewriteRule \.php$ - [F,L]
|
||||||
|
|
||||||
|
# 3) Never redirect local/dev hosts
|
||||||
|
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# 4) HTTPS redirect (enable ONE of these, comment the other)
|
||||||
|
|
||||||
# A) Direct TLS on this server
|
# A) Direct TLS on this server
|
||||||
#RewriteCond %{HTTPS} !=on
|
#RewriteCond %{HTTPS} !=on
|
||||||
@@ -35,7 +52,7 @@ RewriteRule - - [L]
|
|||||||
#RewriteCond %{HTTPS} !=on
|
#RewriteCond %{HTTPS} !=on
|
||||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# Mark versioned assets (?v=...) with env flag for caching rules below
|
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
|
||||||
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||||
RewriteRule ^ - [E=IS_VER:1]
|
RewriteRule ^ - [E=IS_VER:1]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
@@ -98,7 +115,6 @@ RewriteRule ^ - [E=IS_VER:1]
|
|||||||
|
|
||||||
# ---------------- Compression ----------------
|
# ---------------- Compression ----------------
|
||||||
<IfModule mod_brotli.c>
|
<IfModule mod_brotli.c>
|
||||||
# Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only)
|
|
||||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||||
</IfModule>
|
</IfModule>
|
||||||
<IfModule mod_deflate.c>
|
<IfModule mod_deflate.c>
|
||||||
|
|||||||
24
public/api/file/downloadZipFile.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/downloadZipFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/downloadZipFile.php",
|
||||||
|
* summary="Download a finished ZIP by token",
|
||||||
|
* description="Streams the zip once; token is one-shot.",
|
||||||
|
* operationId="downloadZipFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
|
||||||
|
* @OA\Response(response=200, description="ZIP stream"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->downloadZipFile();
|
||||||
23
public/api/file/zipStatus.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/zipStatus.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/zipStatus.php",
|
||||||
|
* summary="Check status of a background ZIP build",
|
||||||
|
* description="Returns status for the authenticated user's token.",
|
||||||
|
* operationId="zipStatus",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Response(response=200, description="Status payload"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->zipStatus();
|
||||||
17
public/api/folder/getFolderColors.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->getFolderColors(); // echoes JSON + status codes
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('getFolderColors failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
30
public/api/folder/isEmpty.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/folder/isEmpty.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
// Snapshot then release session lock so parallel requests don’t block
|
||||||
|
$user = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
];
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// Input
|
||||||
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
|
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
||||||
|
|
||||||
|
// Delegate to controller (model handles ACL + path safety)
|
||||||
|
$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),
|
||||||
|
]);
|
||||||
17
public/api/folder/saveFolderColor.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('saveFolderColor failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -62,6 +62,51 @@ body {
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.zones-toggle { left: 85px !important; }
|
.zones-toggle { left: 85px !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Optional tokens */
|
||||||
|
:root{
|
||||||
|
--filr-accent-500:#008CB4; /* base */
|
||||||
|
--filr-accent-600:#00789A; /* hover */
|
||||||
|
--filr-accent-700:#006882; /* active/border */
|
||||||
|
--filr-accent-ring:rgba(0,140,180,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.btn-color-folder{
|
||||||
|
display:inline-flex; align-items:center; gap:6px;
|
||||||
|
background:var(--filr-accent-500);
|
||||||
|
border:1px solid var(--filr-accent-700);
|
||||||
|
color:#fff; /* ensure white text */
|
||||||
|
}
|
||||||
|
.btn-color-folder .material-icons{
|
||||||
|
color:currentColor; /* makes icon white too */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-color-folder:hover,
|
||||||
|
.btn-color-folder:focus-visible{
|
||||||
|
background:var(--filr-accent-600);
|
||||||
|
border-color:var(--filr-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-color-folder:active{
|
||||||
|
background:var(--filr-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-color-folder:focus-visible{
|
||||||
|
outline:2px solid var(--filr-accent-ring);
|
||||||
|
outline-offset:2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode: start slightly deeper so it doesn't glow */
|
||||||
|
.dark-mode .btn-color-folder{
|
||||||
|
background:var(--filr-accent-600);
|
||||||
|
border-color:var(--filr-accent-700);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
.dark-mode .btn-color-folder:hover,
|
||||||
|
.dark-mode .btn-color-folder:focus-visible{
|
||||||
|
background:var(--filr-accent-700);
|
||||||
|
}
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
HEADER & NAVIGATION
|
HEADER & NAVIGATION
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
@@ -141,7 +186,15 @@ body {
|
|||||||
}#userDropdownToggle {
|
}#userDropdownToggle {
|
||||||
border-radius: 4px !important;
|
border-radius: 4px !important;
|
||||||
padding: 6px 10px !important;
|
padding: 6px 10px !important;
|
||||||
}.header-buttons button:hover {
|
}
|
||||||
|
|
||||||
|
#headerDropArea.header-drop-zone{
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end; /* buttons to the right */
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px; /* so the label has room */
|
||||||
|
}
|
||||||
|
.header-buttons button:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -686,11 +739,167 @@ body {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}#fileList table tr:nth-child(even) {
|
}#fileList table tr:nth-child(even) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}#fileList table tr:hover {
|
}
|
||||||
background-color: #e0e0e0;
|
/* --- File list rows: match folder-tree hover/selected --- */
|
||||||
}.dark-mode #fileList table tr:hover {
|
:root {
|
||||||
background-color: #444;
|
--filr-row-hover-bg: rgba(122,179,255,.14);
|
||||||
}#fileListTitle {
|
--filr-row-selected-bg: rgba(122,179,255,.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Let cell corners round like a pill */
|
||||||
|
#fileList table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Reset any conflicting backgrounds (Bootstrap etc.) inside #fileList only ===== */
|
||||||
|
#fileList table tbody tr,
|
||||||
|
#fileList table tbody tr > td {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kill Bootstrap hover/zebra just for this table */
|
||||||
|
#fileList table.table-hover tbody tr:hover > * { background-color: transparent !important; }
|
||||||
|
#fileList table.table-striped > tbody > tr:nth-of-type(odd) > * { background-color: transparent !important; }
|
||||||
|
|
||||||
|
/* ===== Our look, scoped to the table we tagged in JS ===== */
|
||||||
|
#fileList table.filr-table tbody tr,
|
||||||
|
#fileList table.filr-table tbody td {
|
||||||
|
transition: background-color .12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover (when not selected) */
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||||
|
background: var(--filr-row-hover-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected (support a few legacy class names just in case) */
|
||||||
|
#fileList table.filr-table tbody tr.selected > td,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td {
|
||||||
|
background: var(--filr-row-selected-bg) !important;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rounded “pill” ends on hover/selected */
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus visibility */
|
||||||
|
#fileList table.filr-table tbody tr:focus-within > td {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode #fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||||
|
background: var(--filr-row-hover-bg) !important;
|
||||||
|
}
|
||||||
|
.dark-mode #fileList table.filr-table tbody tr.selected > td,
|
||||||
|
.dark-mode #fileList table.filr-table tbody tr.row-selected > td,
|
||||||
|
.dark-mode #fileList table.filr-table tbody tr.selected-row > td,
|
||||||
|
.dark-mode #fileList table.filr-table tbody tr.is-selected > td {
|
||||||
|
background: var(--filr-row-selected-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.filr-table {
|
||||||
|
--bs-table-hover-color: inherit;
|
||||||
|
--bs-table-striped-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table-hover tbody tr:hover,
|
||||||
|
#fileList table.table-hover tbody tr:hover > td {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode #fileList table.filr-table tbody td a,
|
||||||
|
.dark-mode #fileList table.filr-table tbody td a:hover {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
:root{
|
||||||
|
--filr-row-outline: rgba(122,179,255,.45);
|
||||||
|
--filr-row-outline-hover: rgba(122,179,255,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.filr-table > :not(caption) > * > * { border: 0 !important; }
|
||||||
|
#fileList table.filr-table td,
|
||||||
|
#fileList table.filr-table th { box-shadow: none !important; }
|
||||||
|
|
||||||
|
#fileList table.filr-table tbody tr.selected > td,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td {
|
||||||
|
background: var(--filr-row-selected-bg) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--filr-row-outline),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline);
|
||||||
|
}
|
||||||
|
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 0 0 var(--filr-row-outline),
|
||||||
|
inset 0 1px 0 var(--filr-row-outline),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline);
|
||||||
|
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||||
|
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||||
|
box-shadow:
|
||||||
|
inset -1px 0 0 var(--filr-row-outline),
|
||||||
|
inset 0 1px 0 var(--filr-row-outline),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline);
|
||||||
|
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||||
|
background: var(--filr-row-hover-bg) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||||
|
}
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child {
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 0 0 var(--filr-row-outline-hover),
|
||||||
|
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||||
|
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child {
|
||||||
|
box-shadow:
|
||||||
|
inset -1px 0 0 var(--filr-row-outline-hover),
|
||||||
|
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||||
|
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||||
|
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.filr-table tbody tr:focus-within > td { outline: none; }
|
||||||
|
#fileList table.filr-table tbody tr:focus-within > td:first-child,
|
||||||
|
#fileList table.filr-table tbody tr:focus-within > td:last-child {
|
||||||
|
outline: 2px solid #8ab4f8; outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileListTitle {
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
word-wrap: break-word !important;
|
word-wrap: break-word !important;
|
||||||
overflow-wrap: break-word !important;
|
overflow-wrap: break-word !important;
|
||||||
@@ -793,14 +1002,17 @@ body {
|
|||||||
}
|
}
|
||||||
#uploadForm {
|
#uploadForm {
|
||||||
display: none;
|
display: none;
|
||||||
}.folder-actions {
|
}
|
||||||
display: flex;
|
.folder-actions {
|
||||||
flex-wrap: nowrap;
|
display: flex;
|
||||||
padding-left: 8px;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
gap: 2px;
|
||||||
padding-top: 10px;
|
flex-wrap: wrap;
|
||||||
}@media (min-width: 600px) and (max-width: 992px) {
|
white-space: normal;
|
||||||
|
margin: 0; /* no hacks needed */
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) and (max-width: 992px) {
|
||||||
.folder-actions {
|
.folder-actions {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}}
|
}}
|
||||||
@@ -813,10 +1025,8 @@ body {
|
|||||||
}.folder-actions .material-icons {
|
}.folder-actions .material-icons {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
vertical-align: -2px;
|
vertical-align: -2px;
|
||||||
}.folder-actions .btn + .btn {
|
|
||||||
margin-left: 6px;
|
|
||||||
}.folder-actions .btn {
|
}.folder-actions .btn {
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -826,7 +1036,7 @@ body {
|
|||||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}.folder-actions .material-icons {
|
}.folder-actions .material-icons {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
vertical-align: -2px;
|
vertical-align: -2px;
|
||||||
transition: transform 120ms ease;
|
transition: transform 120ms ease;
|
||||||
}.folder-actions .btn:hover,
|
}.folder-actions .btn:hover,
|
||||||
@@ -946,7 +1156,7 @@ body {
|
|||||||
#fileListTitle {
|
#fileListTitle {
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 10px;
|
||||||
}.file-list-actions {
|
}.file-list-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1124,14 +1334,14 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}.folder-tree {
|
}.folder-tree {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding-left: 10px;
|
padding-left: 5px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}.folder-tree.collapsed {
|
}.folder-tree.collapsed {
|
||||||
display: none;
|
display: none;
|
||||||
}.folder-tree.expanded {
|
}.folder-tree.expanded {
|
||||||
display: block;
|
display: block;
|
||||||
}.folder-item {
|
}.folder-item {
|
||||||
margin: 4px 0;
|
margin: 2px 0;
|
||||||
display: block;
|
display: block;
|
||||||
}.folder-toggle {
|
}.folder-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1141,9 +1351,10 @@ body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}.folder-indent-placeholder {
|
}.folder-indent-placeholder {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 30px;
|
width: 5px;
|
||||||
}#folderTreeContainer {
|
}#folderTreeContainer {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-left: 10px;
|
||||||
}.folder-option {
|
}.folder-option {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}.folder-option:hover {
|
}.folder-option:hover {
|
||||||
@@ -1377,8 +1588,6 @@ body {
|
|||||||
}.dark-mode table {
|
}.dark-mode table {
|
||||||
background-color: #2c2c2c;
|
background-color: #2c2c2c;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
}.dark-mode table tr:hover {
|
|
||||||
background-color: #444;
|
|
||||||
}.dark-mode #uploadProgressContainer .progress {
|
}.dark-mode #uploadProgressContainer .progress {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
}.dark-mode #uploadProgressContainer .progress-bar {
|
}.dark-mode #uploadProgressContainer .progress-bar {
|
||||||
@@ -1524,7 +1733,16 @@ body {
|
|||||||
.drag-header.active {
|
.drag-header.active {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
height: 750px;
|
height: 750px;
|
||||||
}.main-column {
|
}
|
||||||
|
/* Fixed-width sidebar (always 350px) */
|
||||||
|
#sidebarDropArea{
|
||||||
|
width: 350px;
|
||||||
|
min-width: 350px;
|
||||||
|
max-width: 350px;
|
||||||
|
flex: 0 0 350px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.main-column {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
transition: margin-left 0.3s ease;
|
transition: margin-left 0.3s ease;
|
||||||
}#uploadFolderRow {
|
}#uploadFolderRow {
|
||||||
@@ -1592,8 +1810,8 @@ body {
|
|||||||
}#sidebarDropArea,
|
}#sidebarDropArea,
|
||||||
#uploadFolderRow {
|
#uploadFolderRow {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
}
|
||||||
}.dark-mode #sidebarDropArea,
|
.dark-mode #sidebarDropArea,
|
||||||
.dark-mode #uploadFolderRow {
|
.dark-mode #uploadFolderRow {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}.dark-mode #sidebarDropArea.highlight,
|
}.dark-mode #sidebarDropArea.highlight,
|
||||||
@@ -1607,8 +1825,6 @@ body {
|
|||||||
border: none !important;
|
border: none !important;
|
||||||
}.dragging:focus {
|
}.dragging:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}#sidebarDropArea > .card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}.card {
|
}.card {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -1626,6 +1842,7 @@ body {
|
|||||||
}.custom-folder-card-body {
|
}.custom-folder-card-body {
|
||||||
padding-top: 5px !important;
|
padding-top: 5px !important;
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
}#addUserModal,
|
}#addUserModal,
|
||||||
#removeUserModal {
|
#removeUserModal {
|
||||||
z-index: 5000 !important;
|
z-index: 5000 !important;
|
||||||
@@ -1705,8 +1922,9 @@ body {
|
|||||||
border: 2px dashed #555;
|
border: 2px dashed #555;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}.header-drop-zone.drag-active:empty::before {
|
}.header-drop-zone.drag-active:empty::before {
|
||||||
content: "Drop";
|
content: "Drop Zone";
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
padding-right: 6px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
||||||
#fileList tbody tr.clickable-row {
|
#fileList tbody tr.clickable-row {
|
||||||
@@ -1812,34 +2030,74 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}.folder-strip-container {
|
}
|
||||||
display: flex;
|
|
||||||
|
.folder-strip-container {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 0px !important;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 10px 14px;
|
||||||
padding: 8px 0;
|
align-content: flex-start; /* multi-line wrap stays top-aligned */
|
||||||
}.folder-strip-container .folder-item {
|
padding: 6px 4px;
|
||||||
display: flex;
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 0px !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center; /* horizontal (cross-axis) center */
|
||||||
cursor: pointer;
|
justify-content: center; /* vertical (main-axis) center */
|
||||||
width: 80px;
|
min-width: 0;
|
||||||
|
gap: 2px !important;
|
||||||
|
padding: 6px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}.folder-strip-container .folder-item i.material-icons {
|
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease;
|
||||||
font-size: 28px;
|
}
|
||||||
margin-bottom: 4px;
|
.folder-strip-container .folder-item .folder-svg {
|
||||||
}.folder-strip-container .folder-name {
|
line-height: 0;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item .folder-svg svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-name {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: normal;
|
overflow: visible;
|
||||||
word-break: break-word;
|
text-overflow: clip;
|
||||||
max-width: 80px;
|
line-height: 1.2;
|
||||||
margin-top: 4px;
|
}
|
||||||
}.folder-strip-container .folder-item i.material-icons {
|
|
||||||
color: currentColor;
|
.folder-strip-container .folder-item:hover {
|
||||||
}.folder-strip-container .folder-item:hover {
|
transform: translateY(-1px) scale(1.04);
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(0, 0, 0, 0.04); /* light mode */
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, .15);
|
||||||
}:root {
|
}
|
||||||
|
|
||||||
|
/* Dark mode hover */
|
||||||
|
body.dark-mode .folder-strip-container .folder-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: keyboard focus */
|
||||||
|
.folder-strip-container .folder-item:focus-visible {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
--perm-caret: #444;
|
--perm-caret: #444;
|
||||||
}/* light */
|
}/* light */
|
||||||
.dark-mode {
|
.dark-mode {
|
||||||
@@ -1919,10 +2177,239 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.status-badge.watched {
|
.status-badge.watched {
|
||||||
border-color: rgba(34,197,94,.35); /* green-ish */
|
border-color: rgba(34,197,94,.45); /* green-ish */
|
||||||
background: rgba(34,197,94,.15);
|
background: rgba(34,197,94,.15);
|
||||||
}
|
}
|
||||||
.status-badge.progress {
|
.status-badge.progress {
|
||||||
border-color: rgba(250,204,21,.35); /* amber-ish */
|
border-color: rgba(234,88,12,.55); /* amber-ish */
|
||||||
background: rgba(250,204,21,.15);
|
background: rgba(234,88,12,.18);
|
||||||
}
|
}
|
||||||
|
#downloadProgressModal .modal-body,
|
||||||
|
#downloadProgressModal .rise-modal-body,
|
||||||
|
#downloadProgressModal .modal-content {
|
||||||
|
min-height: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadProgressText {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#downloadProgressBarOuter { height: 10px; }
|
||||||
|
|
||||||
|
/* ===== Folder Tree – theme + structure ===== */
|
||||||
|
#folderTreeContainer {
|
||||||
|
/* Theme vars (light mode defaults) */
|
||||||
|
--filr-folder-front: #f6b84e;
|
||||||
|
--filr-folder-back: #ffd36e;
|
||||||
|
--filr-folder-stroke:#a87312;
|
||||||
|
--filr-paper-fill: #ffffff;
|
||||||
|
--filr-paper-stroke: #9fb3d6; /* slightly darker for sharper paper */
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
--row-h: 28px;
|
||||||
|
--twisty: 24px;
|
||||||
|
--twisty-gap: -5px;
|
||||||
|
--icon-size: 24px;
|
||||||
|
--icon-gap: 6px;
|
||||||
|
--indent: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||||
|
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--row-h);
|
||||||
|
height: auto;
|
||||||
|
padding-left: calc(var(--twisty) + var(--twisty-gap));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chevron + spacer (centered vertically) */
|
||||||
|
#folderTreeContainer .folder-row > button.folder-toggle,
|
||||||
|
#folderTreeContainer .folder-row > .folder-spacer {
|
||||||
|
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
width: var(--twisty); height: var(--twisty);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px solid transparent; border-radius: 6px;
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-row > button.folder-toggle::before {
|
||||||
|
content: "▸"; font-size: calc(var(--twisty) * 0.8); line-height: 1;
|
||||||
|
}
|
||||||
|
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
|
||||||
|
> .folder-row > button.folder-toggle::before,
|
||||||
|
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
|
||||||
|
#folderTreeContainer .folder-row > button.folder-toggle:hover {
|
||||||
|
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row "pill" that hugs content and wraps */
|
||||||
|
#folderTreeContainer .folder-option {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 1 auto; /* don't stretch full width */
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--icon-gap);
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--row-h);
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal; /* allow wrapping */
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-option:hover {
|
||||||
|
background: rgba(122,179,255,.14);
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-option.selected {
|
||||||
|
background: rgba(122,179,255,.24);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label must be shrinkable so wrapping works */
|
||||||
|
#folderTreeContainer .folder-label {
|
||||||
|
flex: 1 1 120px;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon box */
|
||||||
|
#folderTreeContainer .folder-icon {
|
||||||
|
flex: 0 0 var(--icon-size);
|
||||||
|
width: var(--icon-size); height: var(--icon-size);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-icon svg {
|
||||||
|
width: 100%; height: 100%; display: block;
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Make folder tree outline match folder strip */
|
||||||
|
#folderTreeContainer .folder-icon .folder-front,
|
||||||
|
#folderTreeContainer .folder-icon .folder-back {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: var(--filr-folder-stroke);
|
||||||
|
stroke-width: .5;
|
||||||
|
paint-order: fill stroke;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||||
|
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-icon .paper {
|
||||||
|
fill: var(--filr-paper-fill);
|
||||||
|
stroke: var(--filr-paper-stroke);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-icon .paper-fold { fill: var(--filr-paper-stroke); }
|
||||||
|
#folderTreeContainer .folder-icon .paper-line {
|
||||||
|
stroke: var(--filr-paper-stroke); stroke-width: 1.5;
|
||||||
|
stroke-linecap: round; fill: none; opacity: .95;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-icon .lip-highlight {
|
||||||
|
stroke: #ffffff; stroke-opacity: .35; stroke-width: .9;
|
||||||
|
fill: none; vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-icon,
|
||||||
|
#folderTreeContainer .folder-label { transform: none !important; }
|
||||||
|
|
||||||
|
/* ===== File List Strip – color the shared folderSVG() ===== */
|
||||||
|
.folder-strip-container .folder-svg svg {
|
||||||
|
display: block;
|
||||||
|
shape-rendering: crispEdges;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
/* defaults — overridden per-tile via inline CSS vars set in JS */
|
||||||
|
--filr-folder-front: #f6b84e;
|
||||||
|
--filr-folder-back: #ffd36e;
|
||||||
|
--filr-folder-stroke: #a87312;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-svg .folder-front,
|
||||||
|
.folder-strip-container .folder-svg .folder-back {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: var(--filr-folder-stroke);
|
||||||
|
stroke-width: .5;
|
||||||
|
paint-order: fill stroke;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-svg .folder-front { color: var(--filr-folder-front); }
|
||||||
|
.folder-strip-container .folder-svg .folder-back { color: var(--filr-folder-back); }
|
||||||
|
|
||||||
|
.folder-strip-container .folder-svg .paper {
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #b2c2db; /* light mode */
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-svg .paper-fold { fill: #b2c2db; }
|
||||||
|
.folder-strip-container .folder-svg .paper-line {
|
||||||
|
stroke: #b2c2db; stroke-width: 1; stroke-linecap: round; fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-svg .lip-highlight {
|
||||||
|
stroke: rgba(255,255,255,.45); stroke-width: .8; fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-icon .folder-front,
|
||||||
|
#folderTreeContainer .folder-icon .folder-back,
|
||||||
|
.folder-strip-container .folder-svg .folder-front,
|
||||||
|
.folder-strip-container .folder-svg .folder-back,
|
||||||
|
#folderTreeContainer .folder-icon .lip-highlight,
|
||||||
|
.folder-strip-container .folder-svg .lip-highlight {
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure we’re not forcing crispEdges anywhere */
|
||||||
|
.folder-strip-container .folder-svg svg,
|
||||||
|
#folderTreeContainer .folder-icon svg { shape-rendering: geometricPrecision !important; }
|
||||||
|
|
||||||
|
@media (max-resolution: 1.5dppx) {
|
||||||
|
#folderTreeContainer .folder-icon .folder-front,
|
||||||
|
#folderTreeContainer .folder-icon .folder-back { stroke-width: .6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Scribble (the handwriting line) */
|
||||||
|
#folderTreeContainer .folder-icon .paper-ink,
|
||||||
|
.folder-strip-container .folder-svg .paper-ink {
|
||||||
|
stroke: #4da3ff;
|
||||||
|
stroke-width: .9;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
fill: none;
|
||||||
|
opacity: .85;
|
||||||
|
paint-order: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tree @ 24px icon */
|
||||||
|
#folderTreeContainer .folder-icon .folder-front,
|
||||||
|
#folderTreeContainer .folder-icon .folder-back,
|
||||||
|
#folderTreeContainer .folder-icon .paper-line,
|
||||||
|
#folderTreeContainer .folder-icon .paper-ink,
|
||||||
|
#folderTreeContainer .folder-icon .lip-highlight { stroke-width: .6px; }
|
||||||
|
|
||||||
|
/* strip @ 48px icon (2× bigger), halve stroke width to look the same */
|
||||||
|
.folder-strip-container .folder-svg .folder-front,
|
||||||
|
.folder-strip-container .folder-svg .folder-back,
|
||||||
|
.folder-strip-container .folder-svg .paper-line,
|
||||||
|
.folder-strip-container .folder-svg .paper-ink,
|
||||||
|
.folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; }
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,17 +3,24 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||||
<style id="pretheme-css">
|
<style id="pretheme-css">
|
||||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||||
</style>
|
</style>
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||||
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Critical CSS -->
|
<!-- Critical CSS -->
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
@@ -245,6 +252,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
|
||||||
|
<i class="material-icons">palette</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
@@ -345,6 +355,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 -->
|
||||||
@@ -484,6 +498,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>
|
||||||
@@ -58,7 +58,7 @@ function wireHeaderTitleLive() {
|
|||||||
|
|
||||||
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
||||||
const type = isSecret ? 'password' : 'text';
|
const type = isSecret ? 'password' : 'text';
|
||||||
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
|
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"';
|
||||||
const replaceBtn = hasValue
|
const replaceBtn = hasValue
|
||||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||||
: '';
|
: '';
|
||||||
@@ -1070,11 +1070,15 @@ function handleSave() {
|
|||||||
const idEl = document.getElementById("oidcClientId");
|
const idEl = document.getElementById("oidcClientId");
|
||||||
const scEl = document.getElementById("oidcClientSecret");
|
const scEl = document.getElementById("oidcClientSecret");
|
||||||
|
|
||||||
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
|
const idVal = idEl?.value.trim() || '';
|
||||||
payload.oidc.clientId = idEl.value.trim();
|
const secVal = scEl?.value.trim() || '';
|
||||||
|
const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet
|
||||||
|
const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet
|
||||||
|
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
|
||||||
|
payload.oidc.clientId = idVal;
|
||||||
}
|
}
|
||||||
if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
|
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
|
||||||
payload.oidc.clientSecret = scEl.value.trim();
|
payload.oidc.clientSecret = secVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ooSecretEl = document.getElementById("ooJwtSecret");
|
const ooSecretEl = document.getElementById("ooJwtSecret");
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function handleDeleteSelected(e) {
|
export function handleDeleteSelected(e) {
|
||||||
@@ -12,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 });
|
||||||
@@ -20,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) {
|
||||||
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files deleted successfully!");
|
showToast("Selected files deleted successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not delete files"));
|
showToast("Error: " + (data.error || "Could not delete files"));
|
||||||
}
|
}
|
||||||
@@ -119,7 +166,7 @@ export async function handleCreateFile(e) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type':'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': window.csrfToken
|
'X-CSRF-Token': window.csrfToken
|
||||||
},
|
},
|
||||||
// ⚠️ must send `name`, not `filename`
|
// ⚠️ must send `name`, not `filename`
|
||||||
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
|
|||||||
if (!js.success) throw new Error(js.error);
|
if (!js.success) throw new Error(js.error);
|
||||||
showToast(t('file_created'));
|
showToast(t('file_created'));
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +187,7 @@ export async function handleCreateFile(e) {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const cancel = document.getElementById('cancelCreateFile');
|
const cancel = document.getElementById('cancelCreateFile');
|
||||||
const confirm = document.getElementById('confirmCreateFile');
|
const confirm = document.getElementById('confirmCreateFile');
|
||||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||||
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +313,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
if (cancelCreate) {
|
if (cancelCreate) {
|
||||||
cancelCreate.addEventListener('click', () => {
|
cancelCreate.addEventListener('click', () => {
|
||||||
document.getElementById('createFileModal').style.display = 'none';
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
@@ -300,12 +348,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
showToast(t('file_created_successfully'));
|
showToast(t('file_created_successfully'));
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
@@ -321,63 +370,187 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
confirmZipBtn.addEventListener("click", async () => {
|
confirmZipBtn.addEventListener("click", async () => {
|
||||||
// a) Validate ZIP filename
|
// a) Validate ZIP filename
|
||||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||||
if (!zipName) {
|
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
|
||||||
showToast("Please enter a name for the zip file.");
|
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
|
||||||
zipName += ".zip";
|
|
||||||
}
|
|
||||||
|
|
||||||
// b) Hide the name‐input modal, show the spinner modal
|
// b) Hide the name‐input modal, show the progress modal
|
||||||
zipNameModal.style.display = "none";
|
zipNameModal.style.display = "none";
|
||||||
progressModal.style.display = "block";
|
progressModal.style.display = "block";
|
||||||
|
|
||||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
// c) Title text (optional)
|
||||||
const titleEl = document.getElementById("downloadProgressTitle");
|
const titleEl = document.getElementById("downloadProgressTitle");
|
||||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
try {
|
// d) Queue the job
|
||||||
// d) POST and await the ZIP blob
|
const res = await fetch("/api/file/downloadZip.php", {
|
||||||
const res = await fetch("/api/file/downloadZip.php", {
|
method: "POST",
|
||||||
method: "POST",
|
credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
|
||||||
"Content-Type": "application/json",
|
});
|
||||||
"X-CSRF-Token": window.csrfToken
|
const jsr = await res.json().catch(() => ({}));
|
||||||
},
|
if (!res.ok || !jsr.ok) {
|
||||||
body: JSON.stringify({
|
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
|
||||||
folder: window.currentFolder || "root",
|
throw new Error(msg);
|
||||||
files: window.filesToDownload
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(txt || `Status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
if (!blob || blob.size === 0) {
|
|
||||||
throw new Error("Received empty ZIP file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// e) Hand off to the browser’s download manager
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = zipName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error downloading ZIP:", err);
|
|
||||||
showToast("Error: " + err.message);
|
|
||||||
} finally {
|
|
||||||
// f) Always hide spinner modal
|
|
||||||
progressModal.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
const token = jsr.token;
|
||||||
|
const statusUrl = jsr.statusUrl;
|
||||||
|
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
|
||||||
|
|
||||||
|
// Ensure a progress UI exists in the modal
|
||||||
|
function ensureZipProgressUI() {
|
||||||
|
const modalEl = document.getElementById("downloadProgressModal");
|
||||||
|
if (!modalEl) {
|
||||||
|
// really shouldn't happen, but fall back to body
|
||||||
|
console.warn("downloadProgressModal not found; falling back to document.body");
|
||||||
|
}
|
||||||
|
// Prefer a dedicated content node inside the modal
|
||||||
|
let host =
|
||||||
|
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-content")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".content")) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// If no suitable container, create one inside the modal
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.id = "downloadProgressContent";
|
||||||
|
(modalEl || document.body).appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: ensure/move an element with given id into host
|
||||||
|
function ensureInHost(id, tag, init) {
|
||||||
|
let el = document.getElementById(id);
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tag);
|
||||||
|
el.id = id;
|
||||||
|
if (typeof init === "function") init(el);
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
|
||||||
|
el.style.marginBottom = "8px";
|
||||||
|
el.textContent = "Preparing…";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar (native <progress>)
|
||||||
|
const bar = (function () {
|
||||||
|
let el = document.getElementById("downloadProgressBar");
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("progress");
|
||||||
|
el.id = "downloadProgressBar";
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
el.max = 100;
|
||||||
|
el.value = 0;
|
||||||
|
el.style.display = ""; // override any inline display:none
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.height = "1.1em";
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Text line
|
||||||
|
const text = ensureInHost("downloadProgressText", "div", (el) => {
|
||||||
|
el.style.marginTop = "8px";
|
||||||
|
el.style.fontSize = "0.9rem";
|
||||||
|
el.style.whiteSpace = "nowrap";
|
||||||
|
el.style.overflow = "hidden";
|
||||||
|
el.style.textOverflow = "ellipsis";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional spinner hider
|
||||||
|
const hideSpinner = () => {
|
||||||
|
const sp = document.getElementById("downloadSpinner");
|
||||||
|
if (sp) sp.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
return { bar, text, title, hideSpinner };
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanBytes(n) {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
|
||||||
|
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||||
|
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
||||||
|
}
|
||||||
|
function mmss(sec) {
|
||||||
|
sec = Math.max(0, sec | 0);
|
||||||
|
const m = (sec / 60) | 0, s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui = ensureZipProgressUI();
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
// e) Poll until ready
|
||||||
|
while (true) {
|
||||||
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
|
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
|
||||||
|
credentials: "include", cache: "no-store",
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
if (s.error) throw new Error(s.error);
|
||||||
|
if (ui.title) ui.title.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
|
// --- RENDER PROGRESS ---
|
||||||
|
if (typeof s.pct === "number" && ui.bar && ui.text) {
|
||||||
|
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
|
||||||
|
ui.hideSpinner && ui.hideSpinner();
|
||||||
|
const filesDone = s.filesDone ?? 0;
|
||||||
|
const filesTotal = s.filesTotal ?? 0;
|
||||||
|
const bytesDone = s.bytesDone ?? 0;
|
||||||
|
const bytesTotal = s.bytesTotal ?? 0;
|
||||||
|
|
||||||
|
// Determinate 0–98% while enumerating
|
||||||
|
const pct = Math.max(0, Math.min(98, s.pct | 0));
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
|
||||||
|
ui.bar.value = pct;
|
||||||
|
ui.text.textContent =
|
||||||
|
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
|
||||||
|
} else {
|
||||||
|
// FINALIZING: keep progress at 100% and show timer + selected totals
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
|
||||||
|
ui.bar.value = 100; // lock at 100 during finalizing
|
||||||
|
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
|
||||||
|
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
|
||||||
|
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
|
||||||
|
ui.text.textContent = `Finalizing… ${mmss(since)} — ${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
|
||||||
|
}
|
||||||
|
} else if (ui.text) {
|
||||||
|
ui.text.textContent = "Still preparing…";
|
||||||
|
}
|
||||||
|
// --- /RENDER ---
|
||||||
|
|
||||||
|
if (s.ready) {
|
||||||
|
// Snap to 100 and close modal just before download
|
||||||
|
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
|
||||||
|
progressModal.style.display = "none";
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// f) Trigger download
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = zipName;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
// g) Reset for next time
|
||||||
|
if (ui.bar) ui.bar.value = 0;
|
||||||
|
if (ui.text) ui.text.textContent = "";
|
||||||
|
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -509,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files copied successfully!", 5000);
|
showToast("Selected files copied successfully!", 5000);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||||
}
|
}
|
||||||
@@ -561,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files moved successfully!");
|
showToast("Selected files moved successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not move files"));
|
showToast("Error: " + (data.error || "Could not move files"));
|
||||||
}
|
}
|
||||||
@@ -694,10 +870,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const btn = document.getElementById('createBtn');
|
const btn = document.getElementById('createBtn');
|
||||||
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) => {
|
||||||
@@ -722,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;
|
||||||
@@ -2,124 +2,163 @@
|
|||||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function fileDragStartHandler(event) {
|
/* ---------------- helpers ---------------- */
|
||||||
const row = event.currentTarget;
|
function getRowEl(el) {
|
||||||
let fileNames = [];
|
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||||
|
}
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
function getNameFromAny(el) {
|
||||||
if (selectedCheckboxes.length > 1) {
|
const row = getRowEl(el);
|
||||||
selectedCheckboxes.forEach(chk => {
|
if (!row) return null;
|
||||||
const parentRow = chk.closest("tr");
|
// 1) canonical
|
||||||
if (parentRow) {
|
const n = row.getAttribute('data-file-name');
|
||||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
if (n) return n;
|
||||||
if (cell) {
|
// 2) filename-only span
|
||||||
let rawName = cell.textContent.trim();
|
const span = row.querySelector('.filename-text');
|
||||||
const tagContainer = cell.querySelector(".tag-badges");
|
if (span) return span.textContent.trim();
|
||||||
if (tagContainer) {
|
return null;
|
||||||
const tagText = tagContainer.innerText.trim();
|
}
|
||||||
if (rawName.endsWith(tagText)) {
|
function getSelectedFileNames() {
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||||
}
|
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||||
}
|
// de-dup just in case
|
||||||
fileNames.push(rawName);
|
return Array.from(new Set(names));
|
||||||
}
|
}
|
||||||
}
|
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||||
});
|
const wrap = document.createElement('div');
|
||||||
} else {
|
Object.assign(wrap.style, {
|
||||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
display: 'inline-flex',
|
||||||
if (fileNameCell) {
|
maxWidth: '420px',
|
||||||
let rawName = fileNameCell.textContent.trim();
|
padding: '6px 10px',
|
||||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
backgroundColor: '#333',
|
||||||
if (tagContainer) {
|
color: '#fff',
|
||||||
const tagText = tagContainer.innerText.trim();
|
border: '1px solid #555',
|
||||||
if (rawName.endsWith(tagText)) {
|
borderRadius: '6px',
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
alignItems: 'center',
|
||||||
}
|
gap: '6px',
|
||||||
}
|
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||||
fileNames.push(rawName);
|
fontSize: '12px',
|
||||||
}
|
pointerEvents: 'none'
|
||||||
}
|
});
|
||||||
|
const icon = document.createElement('span');
|
||||||
if (fileNames.length === 0) return;
|
icon.className = 'material-icons';
|
||||||
|
icon.textContent = iconName;
|
||||||
const dragData = fileNames.length === 1
|
const label = document.createElement('span');
|
||||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
// trim long single-name labels
|
||||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
const txt = String(labelText || '');
|
||||||
|
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
wrap.appendChild(icon);
|
||||||
|
wrap.appendChild(label);
|
||||||
let dragImage = document.createElement("div");
|
document.body.appendChild(wrap);
|
||||||
dragImage.style.display = "inline-flex";
|
return wrap;
|
||||||
dragImage.style.width = "auto";
|
|
||||||
dragImage.style.maxWidth = "fit-content";
|
|
||||||
dragImage.style.padding = "6px 10px";
|
|
||||||
dragImage.style.backgroundColor = "#333";
|
|
||||||
dragImage.style.color = "#fff";
|
|
||||||
dragImage.style.border = "1px solid #555";
|
|
||||||
dragImage.style.borderRadius = "4px";
|
|
||||||
dragImage.style.alignItems = "center";
|
|
||||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "material-icons";
|
|
||||||
icon.textContent = "insert_drive_file";
|
|
||||||
icon.style.marginRight = "4px";
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
|
||||||
dragImage.appendChild(icon);
|
|
||||||
dragImage.appendChild(label);
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage);
|
|
||||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(dragImage);
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- drag start (rows/cards) ---------------- */
|
||||||
|
export function fileDragStartHandler(event) {
|
||||||
|
const row = getRowEl(event.currentTarget);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
// Use current selection if present; otherwise drag just this row’s file
|
||||||
|
let names = getSelectedFileNames();
|
||||||
|
if (names.length === 0) {
|
||||||
|
const single = getNameFromAny(row);
|
||||||
|
if (single) names = [single];
|
||||||
|
}
|
||||||
|
if (names.length === 0) return;
|
||||||
|
|
||||||
|
const sourceFolder = window.currentFolder || 'root';
|
||||||
|
const payload = { files: names, sourceFolder };
|
||||||
|
|
||||||
|
// primary payload
|
||||||
|
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||||
|
// fallback (lets some environments read something human)
|
||||||
|
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||||
|
|
||||||
|
// nicer drag image
|
||||||
|
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||||
|
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||||
|
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||||
|
// clean up the ghost as soon as the browser has captured it
|
||||||
|
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- folder targets ---------------- */
|
||||||
export function folderDragOverHandler(event) {
|
export function folderDragOverHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.add("drop-hover");
|
event.currentTarget.classList.add('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDragLeaveHandler(event) {
|
export function folderDragLeaveHandler(event) {
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDropHandler(event) {
|
export async function folderDropHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
|
||||||
let dragData;
|
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||||
|
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||||
|
|| 'root';
|
||||||
|
|
||||||
|
// parse drag payload
|
||||||
|
let dragData = null;
|
||||||
try {
|
try {
|
||||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||||
} catch (e) {
|
dragData = JSON.parse(raw);
|
||||||
console.error("Invalid drag data");
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!dragData) {
|
||||||
|
showToast('Invalid drag data.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragData || !dragData.fileName) return;
|
|
||||||
fetch("/api/file/moveFiles.php", {
|
// normalize names
|
||||||
method: "POST",
|
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||||
credentials: "include",
|
: dragData.fileName ? [dragData.fileName]
|
||||||
headers: {
|
: [];
|
||||||
"Content-Type": "application/json",
|
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
|
||||||
},
|
if (names.length === 0) {
|
||||||
body: JSON.stringify({
|
showToast('No files to move.');
|
||||||
source: dragData.sourceFolder,
|
return;
|
||||||
files: [dragData.fileName],
|
}
|
||||||
destination: dropFolder
|
|
||||||
})
|
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||||
})
|
if (dropFolder === sourceFolder) {
|
||||||
.then(response => response.json())
|
showToast('Source and destination are the same.');
|
||||||
.then(data => {
|
return;
|
||||||
if (data.success) {
|
}
|
||||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
|
||||||
loadFileList(dragData.sourceFolder);
|
// POST move
|
||||||
} else {
|
try {
|
||||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
const res = await fetch('/api/file/moveFiles.php', {
|
||||||
}
|
method: 'POST',
|
||||||
})
|
credentials: 'include',
|
||||||
.catch(error => {
|
headers: {
|
||||||
console.error("Error moving file via drop:", error);
|
'Content-Type': 'application/json',
|
||||||
showToast("Error moving file.");
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: sourceFolder,
|
||||||
|
files: names,
|
||||||
|
destination: dropFolder
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok && data && data.success) {
|
||||||
|
const msg = (names.length === 1)
|
||||||
|
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||||
|
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||||
|
showToast(msg);
|
||||||
|
// Refresh whatever view the user is currently looking at
|
||||||
|
loadFileList(window.currentFolder || sourceFolder);
|
||||||
|
} else {
|
||||||
|
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||||
|
showToast('Error moving file(s): ' + err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error moving file(s):', e);
|
||||||
|
showToast('Error moving file(s).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
|
|||||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||||
|
|
||||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
|
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||||
|
|
||||||
async function fetchOnlyOfficeCapsOnce() {
|
async function fetchOnlyOfficeCapsOnce() {
|
||||||
if (__ooCaps.fetched) return __ooCaps;
|
if (__ooCaps.fetched) return __ooCaps;
|
||||||
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
|
|||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
__ooCaps.enabled = !!j.enabled;
|
__ooCaps.enabled = !!j.enabled;
|
||||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||||
|
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||||
}
|
}
|
||||||
} catch { /* ignore; keep defaults */ }
|
} catch { /* ignore; keep defaults */ }
|
||||||
__ooCaps.fetched = true;
|
__ooCaps.fetched = true;
|
||||||
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(fileName) {
|
|||||||
|
|
||||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||||
|
|
||||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
// ---- script/css single-load with timeout guards ----
|
||||||
let src =
|
|
||||||
srcFromConfig ||
|
|
||||||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
|
|
||||||
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
|
|
||||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
|
||||||
await loadScriptOnce(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openOnlyOffice(fileName, folder) {
|
|
||||||
let editor; // make visible to the whole function
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
|
||||||
const resp = await fetch(url, { credentials: 'include' });
|
|
||||||
|
|
||||||
const text = await resp.text();
|
|
||||||
let cfg;
|
|
||||||
try { cfg = JSON.parse(text); } catch {
|
|
||||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
|
||||||
|
|
||||||
// Must be absolute
|
|
||||||
const docUrl = cfg?.document?.url;
|
|
||||||
const cbUrl = cfg?.editorConfig?.callbackUrl;
|
|
||||||
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
|
|
||||||
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load DocsAPI if needed
|
|
||||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
|
||||||
|
|
||||||
// Modal
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.id = 'ooEditorModal';
|
|
||||||
modal.classList.add('modal', 'editor-modal');
|
|
||||||
modal.setAttribute('tabindex', '-1');
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="editor-header">
|
|
||||||
<h3 class="editor-title">
|
|
||||||
${t("editing")}: ${escapeHTML(fileName)}
|
|
||||||
</h3>
|
|
||||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="editor-body" style="flex:1;min-height:200px">
|
|
||||||
<div id="oo-editor" style="width:100%;height:100%"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
modal.style.display = 'block';
|
|
||||||
modal.focus();
|
|
||||||
|
|
||||||
// We’ll fill this after wiring the toggle, so destroy() can unhook it
|
|
||||||
let removeThemeListener = () => {};
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
try { editor?.destroyEditor?.(); } catch {}
|
|
||||||
try { removeThemeListener(); } catch {}
|
|
||||||
try { modal.remove(); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
|
|
||||||
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
|
|
||||||
|
|
||||||
// Let DS request closing
|
|
||||||
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
|
|
||||||
|
|
||||||
// Initial theme
|
|
||||||
const isDark =
|
|
||||||
document.documentElement.classList.contains('dark-mode') ||
|
|
||||||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
|
||||||
|
|
||||||
cfg.editorConfig = cfg.editorConfig || {};
|
|
||||||
cfg.editorConfig.customization = Object.assign(
|
|
||||||
{},
|
|
||||||
cfg.editorConfig.customization,
|
|
||||||
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
|
|
||||||
);
|
|
||||||
|
|
||||||
// Launch editor
|
|
||||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
|
||||||
|
|
||||||
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
|
|
||||||
const darkToggle = document.getElementById('darkModeToggle');
|
|
||||||
const onDarkToggle = () => {
|
|
||||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
|
||||||
if (editor && typeof editor.setTheme === 'function') {
|
|
||||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (darkToggle) {
|
|
||||||
darkToggle.addEventListener('click', onDarkToggle);
|
|
||||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ONLYOFFICE] failed to open:', e);
|
|
||||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
const _loadedScripts = new Set();
|
const _loadedScripts = new Set();
|
||||||
const _loadedCss = new Set();
|
const _loadedCss = new Set();
|
||||||
let _corePromise = null;
|
let _corePromise = null;
|
||||||
|
|
||||||
function loadScriptOnce(url) {
|
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (_loadedScripts.has(url)) return resolve();
|
if (_loadedScripts.has(url)) return resolve();
|
||||||
const s = document.createElement("script");
|
const s = document.createElement("script");
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { s.remove(); } catch { }
|
||||||
|
reject(new Error(`Timeout loading: ${url}`));
|
||||||
|
}, timeoutMs);
|
||||||
s.src = url;
|
s.src = url;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,7 +143,6 @@ async function ensureCore() {
|
|||||||
async function loadSingleMode(name) {
|
async function loadSingleMode(name) {
|
||||||
const rel = MODE_URL[name];
|
const rel = MODE_URL[name];
|
||||||
if (!rel) return;
|
if (!rel) return;
|
||||||
// prepend base if needed
|
|
||||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||||
await loadScriptOnce(url);
|
await loadScriptOnce(url);
|
||||||
}
|
}
|
||||||
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public helper for callers (we keep your existing function name in use):
|
// Public helper for callers (we keep your existing function name in use):
|
||||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||||
// ==== /CodeMirror lazy loader ===============================================
|
// ==== /CodeMirror lazy loader ===============================================
|
||||||
|
|
||||||
|
// ---- OO preconnect / prewarm ----
|
||||||
|
function injectOOPreconnect(origin) {
|
||||||
|
try {
|
||||||
|
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||||
|
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||||
|
document.head.appendChild(make('dns-prefetch'));
|
||||||
|
document.head.appendChild(make('preconnect'));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||||
|
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||||
|
let src = srcFromConfig;
|
||||||
|
if (!src) {
|
||||||
|
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||||
|
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||||
|
} else {
|
||||||
|
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||||
|
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||||
|
try {
|
||||||
|
console.time('oo:api.js');
|
||||||
|
await loadScriptOnce(src);
|
||||||
|
} catch (e) {
|
||||||
|
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||||
|
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.timeEnd('oo:api.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||||
|
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||||
|
const OO_WARM_MS = 300;
|
||||||
|
|
||||||
|
function ensureOoModalCss() {
|
||||||
|
const prev = document.getElementById('ooEditorModalCss');
|
||||||
|
if (prev) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'ooEditorModalCss';
|
||||||
|
style.textContent = `
|
||||||
|
#ooEditorModal{
|
||||||
|
--oo-header-h: 40px;
|
||||||
|
--oo-header-pad-v: 12px;
|
||||||
|
--oo-header-pad-h: 18px;
|
||||||
|
--oo-logo-h: 26px; /* tweak logo size */
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal{
|
||||||
|
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||||
|
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||||
|
background:var(--oo-modal-bg,#111)!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||||
|
#ooEditorModal .editor-header{
|
||||||
|
position:relative; display:flex; align-items:center; gap:12px;
|
||||||
|
min-height:var(--oo-header-h);
|
||||||
|
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||||
|
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||||
|
border-bottom:1px solid rgba(0,0,0,.15);
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-logo{
|
||||||
|
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||||
|
display:block; user-select:none; -webkit-user-drag:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-title{
|
||||||
|
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||||
|
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||||
|
flex:1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your scoped close button style */
|
||||||
|
#ooEditorModal .editor-close-btn{
|
||||||
|
position:absolute; top:5px; right:10px;
|
||||||
|
display:flex; justify-content:center; align-items:center;
|
||||||
|
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||||
|
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||||
|
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||||
|
transition:all .3s ease-in-out;
|
||||||
|
}
|
||||||
|
#ooEditorModal .editor-close-btn:hover{
|
||||||
|
color:#fff; background-color:#ff4d4d;
|
||||||
|
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||||
|
}
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||||
|
|
||||||
|
#ooEditorModal .editor-body{
|
||||||
|
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||||
|
}
|
||||||
|
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||||
|
|
||||||
|
#ooEditorModal .oo-warm-overlay{
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-aware background so there’s no white/gray edge
|
||||||
|
function applyModalBg(modal){
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||||
|
|| (isDark ? '#121212' : '#ffffff');
|
||||||
|
modal.style.setProperty('--oo-modal-bg', bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPageScroll(on){
|
||||||
|
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOoFullscreenModal(){
|
||||||
|
ensureOoModalCss();
|
||||||
|
let modal = document.getElementById('ooEditorModal');
|
||||||
|
if (!modal){
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'ooEditorModal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="editor-header">
|
||||||
|
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||||
|
<h3 class="editor-title"></h3>
|
||||||
|
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body">
|
||||||
|
<div id="oo-editor"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
} else {
|
||||||
|
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||||
|
// ensure logo exists and is placed before title when reusing
|
||||||
|
const header = modal.querySelector('.editor-header');
|
||||||
|
if (!header.querySelector('.editor-logo')){
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'editor-logo';
|
||||||
|
img.src = '/assets/logo.svg';
|
||||||
|
img.alt = 'FileRise logo';
|
||||||
|
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||||
|
} else {
|
||||||
|
// make sure order is logo -> title
|
||||||
|
const logo = header.querySelector('.editor-logo');
|
||||||
|
const title = header.querySelector('.editor-title');
|
||||||
|
if (logo.nextElementSibling !== title){
|
||||||
|
header.insertBefore(logo, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.focus();
|
||||||
|
lockPageScroll(true);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay lives INSIDE the modal body
|
||||||
|
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||||
|
if (!modal) return;
|
||||||
|
const body = modal.querySelector('.editor-body');
|
||||||
|
let ov = body.querySelector('.oo-warm-overlay');
|
||||||
|
if (on){
|
||||||
|
if (!ov){
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.className = 'oo-warm-overlay';
|
||||||
|
ov.textContent = label;
|
||||||
|
body.appendChild(ov);
|
||||||
|
}
|
||||||
|
} else if (ov){
|
||||||
|
ov.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||||
|
async function warmDocServerOnce(cfg){
|
||||||
|
let host = null, warmEditor = null;
|
||||||
|
try{
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||||
|
Object.assign(host.style, {
|
||||||
|
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||||
|
});
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||||
|
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||||
|
|
||||||
|
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||||
|
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||||
|
}catch{} finally{
|
||||||
|
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||||
|
try{ host?.remove(); }catch{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||||
|
async function openOnlyOffice(fileName, folder){
|
||||||
|
let editor = null;
|
||||||
|
let removeThemeListener = () => {};
|
||||||
|
let cfg = null;
|
||||||
|
let userClosed = false;
|
||||||
|
|
||||||
|
// Build our full-screen modal
|
||||||
|
const modal = ensureOoFullscreenModal();
|
||||||
|
const titleEl = modal.querySelector('.editor-title');
|
||||||
|
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||||
|
|
||||||
|
const destroy = (removeModal = true) => {
|
||||||
|
try { editor?.destroyEditor?.(); } catch {}
|
||||||
|
try { removeThemeListener(); } catch {}
|
||||||
|
if (removeModal) { try { modal.remove(); } catch {} }
|
||||||
|
lockPageScroll(false);
|
||||||
|
};
|
||||||
|
const onClose = () => { userClosed = true; destroy(true); };
|
||||||
|
|
||||||
|
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||||
|
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||||
|
|
||||||
|
try{
|
||||||
|
// 1) Fetch config
|
||||||
|
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||||
|
const resp = await fetch(url, { credentials: 'include' });
|
||||||
|
const text = await resp.text();
|
||||||
|
|
||||||
|
try { cfg = JSON.parse(text); } catch {
|
||||||
|
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||||
|
|
||||||
|
// 2) Preconnect + load DocsAPI
|
||||||
|
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||||
|
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||||
|
|
||||||
|
// 3) Theme + base events
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||||
|
cfg.editorConfig = cfg.editorConfig || {};
|
||||||
|
cfg.editorConfig.customization = Object.assign(
|
||||||
|
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||||
|
);
|
||||||
|
cfg.events.onRequestClose = () => onClose();
|
||||||
|
|
||||||
|
// 4) Warm EVERY click
|
||||||
|
if (ALWAYS_WARM_OO && !userClosed){
|
||||||
|
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||||
|
await warmDocServerOnce(cfg);
|
||||||
|
if (userClosed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Launch visible editor in full-screen modal
|
||||||
|
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||||
|
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||||
|
|
||||||
|
// Live theme switching + keep modal bg in sync
|
||||||
|
const darkToggle = document.getElementById('darkModeToggle');
|
||||||
|
const onDarkToggle = () => {
|
||||||
|
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
if (editor && typeof editor.setTheme === 'function') {
|
||||||
|
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
};
|
||||||
|
if (darkToggle) {
|
||||||
|
darkToggle.addEventListener('click', onDarkToggle);
|
||||||
|
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
console.error('[ONLYOFFICE] failed to open:', e);
|
||||||
|
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||||
|
destroy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||||
|
|
||||||
|
// ==== Editor (CodeMirror) path =============================================
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const dot = fileName.lastIndexOf(".");
|
const dot = fileName.lastIndexOf(".");
|
||||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
|
|||||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||||
|
|
||||||
const cmOptions = {
|
const cm = window.CodeMirror.fromTextArea(
|
||||||
lineNumbers: !forcePlainText,
|
|
||||||
mode: initialMode,
|
|
||||||
theme,
|
|
||||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
|
||||||
lineWrapping: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const editor = window.CodeMirror.fromTextArea(
|
|
||||||
document.getElementById("fileEditor"),
|
document.getElementById("fileEditor"),
|
||||||
cmOptions
|
{
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
|
mode: initialMode,
|
||||||
|
theme,
|
||||||
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
|
lineWrapping: false
|
||||||
|
}
|
||||||
);
|
);
|
||||||
window.currentEditor = editor;
|
window.currentEditor = cm;
|
||||||
|
|
||||||
setTimeout(adjustEditorSize, 50);
|
setTimeout(adjustEditorSize, 50);
|
||||||
observeModalResize(modal);
|
observeModalResize(modal);
|
||||||
|
|
||||||
// Font controls (now that editor exists)
|
// Font controls (now that editor exists)
|
||||||
let currentFontSize = 14;
|
let currentFontSize = 14;
|
||||||
const wrapper = editor.getWrapperElement();
|
const wrapper = cm.getWrapperElement();
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
|
|
||||||
decBtn.addEventListener("click", function () {
|
decBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
incBtn.addEventListener("click", function () {
|
incBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
|
|||||||
// Theme switch
|
// Theme switch
|
||||||
function updateEditorTheme() {
|
function updateEditorTheme() {
|
||||||
const isDark = document.body.classList.contains("dark-mode");
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
}
|
}
|
||||||
const toggle = document.getElementById("darkModeToggle");
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
|
|||||||
if (!canceled && !forcePlainText) {
|
if (!canceled && !forcePlainText) {
|
||||||
const nn = normalizeModeName(desiredMode);
|
const nn = normalizeModeName(desiredMode);
|
||||||
if (nn && isModeRegistered(nn)) {
|
if (nn && isModeRegistered(nn)) {
|
||||||
editor.setOption("mode", desiredMode);
|
cm.setOption("mode", desiredMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => { /* stay in plain text */ });
|
||||||
// If the mode truly fails to load, we just stay in plain text
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -123,6 +123,21 @@ export function openShareModal(file, folder) {
|
|||||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||||
|
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||||
|
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
|
||||||
|
const TXT_RE = /\.(txt|rtf|md|log)$/i;
|
||||||
|
|
||||||
|
function getIconForFile(name) {
|
||||||
|
const lower = (name || '').toLowerCase();
|
||||||
|
if (IMG_RE.test(lower)) return 'image';
|
||||||
|
if (VID_RE.test(lower)) return 'ondemand_video';
|
||||||
|
if (AUD_RE.test(lower)) return 'audiotrack';
|
||||||
|
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
|
||||||
|
if (ARCH_RE.test(lower)) return 'archive';
|
||||||
|
if (CODE_RE.test(lower)) return 'code';
|
||||||
|
if (TXT_RE.test(lower)) return 'description';
|
||||||
|
return 'insert_drive_file';
|
||||||
|
}
|
||||||
|
|
||||||
function ensureMediaModal() {
|
function ensureMediaModal() {
|
||||||
let overlay = document.getElementById("filePreviewModal");
|
let overlay = document.getElementById("filePreviewModal");
|
||||||
@@ -152,109 +167,166 @@ function ensureMediaModal() {
|
|||||||
const navFg = '#fff';
|
const navFg = '#fff';
|
||||||
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||||
|
|
||||||
|
// fixed top bar; pad-right to avoid overlap with absolute close “×”
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="modal-content media-modal" style="
|
<div class="modal-content media-modal" style="
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 92vw;
|
max-width: 92vw;
|
||||||
max-height: 92vh;
|
|
||||||
width: 92vw;
|
width: 92vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
height: 92vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 12px;
|
|
||||||
background: ${panelBg};
|
background: ${panelBg};
|
||||||
color: ${textCol};
|
color: ${textCol};
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
display:flex; flex-direction:column;
|
||||||
">
|
">
|
||||||
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);">
|
<!-- Top bar -->
|
||||||
<!-- filename badge (top-left) -->
|
<div class="media-topbar" style="
|
||||||
<div class="media-title-badge" style="
|
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
|
||||||
position:absolute; top:8px; left:12px; max-width:60vw;
|
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
|
||||||
padding:4px 10px; border-radius:10px;
|
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
|
||||||
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'};
|
background:${panelBg};
|
||||||
color: ${isDark ? '#fff' : '#111'};
|
">
|
||||||
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;">
|
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||||
|
<span class="material-icons title-icon" style="
|
||||||
|
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
|
||||||
|
insert_drive_file
|
||||||
|
</span>
|
||||||
|
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
|
||||||
<!-- top-right actions row (aligned with your X at top:10px) -->
|
|
||||||
<div class="media-actions-bar" style="
|
|
||||||
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
|
|
||||||
<span class="status-chip" style="
|
<span class="status-chip" style="
|
||||||
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
||||||
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span>
|
border:1px solid transparent; background:transparent; color:inherit;"></span>
|
||||||
<div class="action-group" style="display:flex; gap:6px;"></div>
|
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- your absolute close X -->
|
<!-- Stage -->
|
||||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">×</span>
|
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||||
|
|
||||||
<!-- centered media -->
|
|
||||||
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||||
|
|
||||||
<!-- high-contrast prev/next -->
|
<!-- prev/next = rounded rectangles with centered glyphs -->
|
||||||
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
||||||
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
height:56px; min-width:48px; padding:0 14px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:38px; line-height:0;
|
||||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
||||||
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
||||||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
height:56px; min-width:48px; padding:0 14px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:38px; line-height:0;
|
||||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Absolute close “×” (like original), themed + hover behavior -->
|
||||||
|
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
|
||||||
|
position:absolute; top:8px; right:10px; z-index:1002;
|
||||||
|
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
|
||||||
|
">×</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// theme the close “×” for visibility + hover rules that match your site:
|
||||||
|
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||||
|
function paintCloseBase() {
|
||||||
|
closeBtn.style.backgroundColor = 'transparent';
|
||||||
|
closeBtn.style.color = '#e11d48'; // base red X
|
||||||
|
closeBtn.style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
function onCloseHoverEnter() {
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
closeBtn.style.backgroundColor = '#ef4444'; // red fill
|
||||||
|
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
|
||||||
|
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
|
||||||
|
}
|
||||||
|
function onCloseHoverLeave() { paintCloseBase(); }
|
||||||
|
paintCloseBase();
|
||||||
|
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
|
||||||
|
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
}
|
}
|
||||||
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
|
closeBtn.addEventListener("click", closeModal);
|
||||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||||
|
|
||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTitle(overlay, name) {
|
function setTitle(overlay, name) {
|
||||||
const el = overlay.querySelector('.media-title-badge');
|
const textEl = overlay.querySelector('.title-text');
|
||||||
if (el) el.textContent = name || '';
|
const iconEl = overlay.querySelector('.title-icon');
|
||||||
|
if (textEl) {
|
||||||
|
textEl.textContent = name || '';
|
||||||
|
textEl.setAttribute('title', name || '');
|
||||||
|
}
|
||||||
|
if (iconEl) {
|
||||||
|
iconEl.textContent = getIconForFile(name);
|
||||||
|
// keep the icon legible in both themes
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||||
|
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMI(name, title) {
|
// Topbar icon (theme-aware) used for image tools + video actions
|
||||||
|
function makeTopIcon(name, title) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.className = `material-icons ${name}`;
|
b.className = 'material-icons';
|
||||||
b.textContent = name; // Material Icons font
|
b.textContent = name;
|
||||||
b.title = title;
|
b.title = title;
|
||||||
|
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
|
||||||
Object.assign(b.style, {
|
Object.assign(b.style, {
|
||||||
width: "32px",
|
width: '32px',
|
||||||
height: "32px",
|
height: '32px',
|
||||||
display: "flex",
|
borderRadius: '8px',
|
||||||
alignItems: "center",
|
display: 'flex',
|
||||||
justifyContent: "center",
|
alignItems: 'center',
|
||||||
background: "rgba(0,0,0,.25)",
|
justifyContent: 'center',
|
||||||
border: "1px solid rgba(255,255,255,.25)",
|
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
|
||||||
cursor: "pointer",
|
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
|
||||||
userSelect: "none",
|
cursor: 'pointer',
|
||||||
fontSize: "20px",
|
fontSize: '20px',
|
||||||
padding: "0",
|
lineHeight: '1',
|
||||||
borderRadius: "8px",
|
color: dark ? '#f5f5f5' : '#111',
|
||||||
color: "#fff",
|
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
|
||||||
lineHeight: "1"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.addEventListener('mouseenter', () => {
|
||||||
|
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||||
|
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
|
||||||
|
});
|
||||||
|
b.addEventListener('mouseleave', () => {
|
||||||
|
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||||
|
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
|
||||||
|
});
|
||||||
|
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNavVisibility(overlay, showPrev, showNext) {
|
function setNavVisibility(overlay, showPrev, showNext) {
|
||||||
const prev = overlay.querySelector('.nav-left');
|
const prev = overlay.querySelector('.nav-left');
|
||||||
const next = overlay.querySelector('.nav-right');
|
const next = overlay.querySelector('.nav-right');
|
||||||
prev.style.display = showPrev ? 'inline-flex' : 'none';
|
prev.style.display = showPrev ? 'flex' : 'none';
|
||||||
next.style.display = showNext ? 'inline-flex' : 'none';
|
next.style.display = showNext ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRowWatchedBadge(name, watched) {
|
function setRowWatchedBadge(name, watched) {
|
||||||
@@ -280,8 +352,8 @@ function setRowWatchedBadge(name, watched) {
|
|||||||
export function previewFile(fileUrl, fileName) {
|
export function previewFile(fileUrl, fileName) {
|
||||||
const overlay = ensureMediaModal();
|
const overlay = ensureMediaModal();
|
||||||
const container = overlay.querySelector(".file-preview-container");
|
const container = overlay.querySelector(".file-preview-container");
|
||||||
const actionWrap = overlay.querySelector(".media-actions-bar .action-group");
|
const actionWrap = overlay.querySelector(".media-right .action-group");
|
||||||
const statusChip = overlay.querySelector(".media-actions-bar .status-chip");
|
const statusChip = overlay.querySelector(".media-right .status-chip");
|
||||||
|
|
||||||
// replace nav buttons to clear old listeners
|
// replace nav buttons to clear old listeners
|
||||||
let prevBtn = overlay.querySelector('.nav-left');
|
let prevBtn = overlay.querySelector('.nav-left');
|
||||||
@@ -320,10 +392,11 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
img.dataset.rotate = 0;
|
img.dataset.rotate = 0;
|
||||||
container.appendChild(img);
|
container.appendChild(img);
|
||||||
|
|
||||||
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In');
|
// topbar-aligned, theme-aware icons
|
||||||
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out');
|
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||||
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left');
|
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||||
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right');
|
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||||
|
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||||
actionWrap.appendChild(zoomInBtn);
|
actionWrap.appendChild(zoomInBtn);
|
||||||
actionWrap.appendChild(zoomOutBtn);
|
actionWrap.appendChild(zoomOutBtn);
|
||||||
actionWrap.appendChild(rotateLeft);
|
actionWrap.appendChild(rotateLeft);
|
||||||
@@ -405,14 +478,11 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
video.style.objectFit = "contain";
|
video.style.objectFit = "contain";
|
||||||
container.appendChild(video);
|
container.appendChild(video);
|
||||||
|
|
||||||
const markBtn = document.createElement('button');
|
// Top-right action icons (Material icons, theme-aware)
|
||||||
const clearBtn = document.createElement('button');
|
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||||
markBtn.className = 'btn btn-sm btn-success';
|
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||||
clearBtn.className = 'btn btn-sm btn-secondary';
|
actionWrap.appendChild(markBtnIcon);
|
||||||
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
|
actionWrap.appendChild(clearBtnIcon);
|
||||||
clearBtn.textContent = t("clear_progress") || "Clear progress";
|
|
||||||
actionWrap.appendChild(markBtn);
|
|
||||||
actionWrap.appendChild(clearBtn);
|
|
||||||
|
|
||||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||||
overlay.mediaType = 'video';
|
overlay.mediaType = 'video';
|
||||||
@@ -453,15 +523,14 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
if (!statusChip) return;
|
if (!statusChip) return;
|
||||||
// Completed
|
// Completed
|
||||||
if (state && state.completed) {
|
if (state && state.completed) {
|
||||||
|
|
||||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||||
statusChip.style.display = 'inline-block';
|
statusChip.style.display = 'inline-block';
|
||||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||||
statusChip.style.color = '#22c55e';
|
statusChip.style.color = '#22c55e';
|
||||||
markBtn.style.display = 'none';
|
markBtnIcon.style.display = 'none';
|
||||||
clearBtn.style.display = '';
|
clearBtnIcon.style.display = '';
|
||||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// In progress
|
// In progress
|
||||||
@@ -469,18 +538,20 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||||
statusChip.textContent = `${pct}%`;
|
statusChip.textContent = `${pct}%`;
|
||||||
statusChip.style.display = 'inline-block';
|
statusChip.style.display = 'inline-block';
|
||||||
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
statusChip.style.background = 'rgba(250,204,21,.15)';
|
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||||
statusChip.style.color = '#facc15';
|
statusChip.style.color = ORANGE_HEX;
|
||||||
markBtn.style.display = '';
|
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||||
clearBtn.style.display = '';
|
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
markBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No progress
|
// No progress
|
||||||
statusChip.style.display = 'none';
|
statusChip.style.display = 'none';
|
||||||
markBtn.style.display = '';
|
markBtnIcon.style.display = '';
|
||||||
clearBtn.style.display = 'none';
|
clearBtnIcon.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindVideoEvents(nm) {
|
function bindVideoEvents(nm) {
|
||||||
@@ -494,8 +565,8 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||||
video.currentTime = state.seconds;
|
video.currentTime = state.seconds;
|
||||||
const seconds = Math.floor(video.currentTime || 0);
|
const seconds = Math.floor(video.currentTime || 0);
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
setFileProgressBadge(nm, seconds, duration);
|
setFileProgressBadge(nm, seconds, duration);
|
||||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||||
} else {
|
} else {
|
||||||
const ls = localStorage.getItem(lsKey(nm));
|
const ls = localStorage.getItem(lsKey(nm));
|
||||||
@@ -528,14 +599,14 @@ setFileProgressBadge(nm, seconds, duration);
|
|||||||
renderStatus({ seconds: duration, duration, completed: true });
|
renderStatus({ seconds: duration, duration, completed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
markBtn.onclick = async () => {
|
markBtnIcon.onclick = async () => {
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||||
showToast(t("marked_viewed") || "Marked as viewed");
|
showToast(t("marked_viewed") || "Marked as viewed");
|
||||||
setFileWatchedBadge(nm, true);
|
setFileWatchedBadge(nm, true);
|
||||||
renderStatus({ seconds: duration, duration, completed: true });
|
renderStatus({ seconds: duration, duration, completed: true });
|
||||||
};
|
};
|
||||||
clearBtn.onclick = async () => {
|
clearBtnIcon.onclick = async () => {
|
||||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||||
showToast(t("progress_cleared") || "Progress cleared");
|
showToast(t("progress_cleared") || "Progress cleared");
|
||||||
|
|||||||
@@ -312,7 +312,13 @@ const translations = {
|
|||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"watched": "Watched",
|
"watched": "Watched",
|
||||||
"reset_progress": "Reset Progress"
|
"reset_progress": "Reset Progress",
|
||||||
|
"color_folder": "Color folder",
|
||||||
|
"choose_color": "Choose a color",
|
||||||
|
"reset_default": "Reset",
|
||||||
|
"save_color": "Save",
|
||||||
|
"folder_color_saved": "Folder color saved.",
|
||||||
|
"folder_color_cleared": "Folder color reset."
|
||||||
},
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -403,39 +403,57 @@ function bindDarkMode() {
|
|||||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||||
try {
|
try {
|
||||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||||
|
|
||||||
// Always keep <title> correct early (no visual flicker)
|
// Always keep <title> correct early (no visual flicker)
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
|
||||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||||
const disableForm = !!lo.disableFormLogin;
|
|
||||||
const disableOIDC = !!lo.disableOIDCLogin;
|
|
||||||
const disableBasic = !!lo.disableBasicAuth;
|
// be tolerant to key variants just in case
|
||||||
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
const row = $('#loginForm');
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
if (row) {
|
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||||
if (disableForm) {
|
|
||||||
row.setAttribute('hidden', '');
|
const showForm = !disableForm;
|
||||||
row.style.display = ''; // don't leave display:none lying around
|
const showOIDC = !disableOIDC;
|
||||||
|
const showBasic = !disableBasic;
|
||||||
|
|
||||||
|
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||||
|
const authForm = $('#authForm'); // inner username/password form
|
||||||
|
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||||
|
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
|
|
||||||
|
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||||
|
if (loginWrap) {
|
||||||
|
const anyMethod = showForm || showOIDC || showBasic;
|
||||||
|
if (anyMethod) {
|
||||||
|
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||||
|
loginWrap.style.display = ''; // let CSS decide
|
||||||
} else {
|
} else {
|
||||||
row.removeAttribute('hidden');
|
loginWrap.setAttribute('hidden', '');
|
||||||
row.style.display = '';
|
loginWrap.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
|
||||||
|
// 2) Toggle the pieces inside the wrapper
|
||||||
|
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||||
|
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||||
|
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||||
|
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||||
|
|
||||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||||
if (phase === 'final') {
|
if (phase === 'final') {
|
||||||
const h1 = document.querySelector('.header-title h1');
|
const h1 = document.querySelector('.header-title h1');
|
||||||
if (h1) {
|
if (h1) {
|
||||||
// prevent i18n or legacy from overwriting it
|
// prevent i18n or legacy from overwriting it
|
||||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||||
|
|
||||||
if (h1.textContent !== title) h1.textContent = title;
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
|
|
||||||
// lock it so late code can't stomp it
|
// lock it so late code can't stomp it
|
||||||
if (!h1.__titleLock) {
|
if (!h1.__titleLock) {
|
||||||
const mo = new MutationObserver(() => {
|
const mo = new MutationObserver(() => {
|
||||||
@@ -1037,6 +1055,21 @@ function bindDarkMode() {
|
|||||||
if (login) login.style.display = '';
|
if (login) login.style.display = '';
|
||||||
// …wire stuff…
|
// …wire stuff…
|
||||||
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
||||||
|
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
|
||||||
|
(() => {
|
||||||
|
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
|
||||||
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
|
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||||
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
|
|
||||||
|
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
|
||||||
|
const qp = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
if (onlyOIDC && qp.get('noauto') !== '1') {
|
||||||
|
const btn = document.getElementById('oidcLoginBtn');
|
||||||
|
if (btn) setTimeout(() => btn.click(), 250);
|
||||||
|
}
|
||||||
|
})();
|
||||||
await revealAppAndHideOverlay();
|
await revealAppAndHideOverlay();
|
||||||
const hb = document.querySelector('.header-buttons');
|
const hb = document.querySelector('.header-buttons');
|
||||||
if (hb) hb.style.visibility = 'hidden';
|
if (hb) hb.style.visibility = 'hidden';
|
||||||
@@ -1102,7 +1135,7 @@ function bindDarkMode() {
|
|||||||
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
||||||
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
|
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
function showConfirm(message, onConfirm) {
|
function showConfirm(message, onConfirm) {
|
||||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
|||||||
toggleVisibility("restoreFilesModal", false);
|
toggleVisibility("restoreFilesModal", false);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
|||||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
@@ -588,7 +589,7 @@ async function initResumableUpload() {
|
|||||||
if (removeBtn) removeBtn.style.display = "none";
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
setTimeout(() => li.remove(), 5000);
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -895,7 +896,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) {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v1.8.7';
|
window.APP_VERSION = 'v1.9.3';
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 666 KiB |
54
scripts/manual-sync.sh
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# === Update FileRise to v1.9.1 (safe rsync) ===
|
||||||
|
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
VER="v1.9.1"
|
||||||
|
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
||||||
|
WEBROOT="/var/www"
|
||||||
|
TMP="/tmp/filerise-update"
|
||||||
|
|
||||||
|
# 0) (optional) quick backup of critical bits
|
||||||
|
stamp="$(date +%F-%H%M)"
|
||||||
|
mkdir -p /root/backups
|
||||||
|
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
||||||
|
public/.htaccess config users uploads metadata || true
|
||||||
|
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||||
|
|
||||||
|
# 1) Fetch the release zip
|
||||||
|
rm -rf "$TMP"
|
||||||
|
mkdir -p "$TMP"
|
||||||
|
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
|
||||||
|
|
||||||
|
# 2) Unzip to a staging dir
|
||||||
|
unzip -q "$TMP/$ASSET" -d "$TMP"
|
||||||
|
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
|
||||||
|
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
|
||||||
|
|
||||||
|
# 3) Sync code into /var/www
|
||||||
|
# - keep public/.htaccess
|
||||||
|
# - keep data dirs and current config.php
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='public/.htaccess' \
|
||||||
|
--exclude='uploads/***' \
|
||||||
|
--exclude='users/***' \
|
||||||
|
--exclude='metadata/***' \
|
||||||
|
--exclude='config/config.php' \
|
||||||
|
--exclude='.github/***' \
|
||||||
|
--exclude='docker-compose.yml' \
|
||||||
|
"$STAGE_DIR"/ "$WEBROOT"/
|
||||||
|
|
||||||
|
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||||
|
chown -R www-data:www-data "$WEBROOT"
|
||||||
|
|
||||||
|
# 5) (optional) Composer autoload optimization if composer is available
|
||||||
|
if command -v composer >/dev/null 2>&1; then
|
||||||
|
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||||
|
systemctl reload apache2 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
||||||
179
src/cli/zip_worker.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../../config/config.php';
|
||||||
|
require __DIR__ . '/../../src/models/FileModel.php';
|
||||||
|
|
||||||
|
$token = $argv[1] ?? '';
|
||||||
|
$token = preg_replace('/[^a-f0-9]/','',$token);
|
||||||
|
if ($token === '') { fwrite(STDERR, "No token\n"); exit(1); }
|
||||||
|
|
||||||
|
$root = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||||
|
$tokDir = $root . '/.tokens';
|
||||||
|
$logDir = $root . '/.logs';
|
||||||
|
@mkdir($tokDir, 0775, true);
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
|
||||||
|
$tokFile = $tokDir . '/' . $token . '.json';
|
||||||
|
$logFile = $logDir . '/WORKER-' . $token . '.log';
|
||||||
|
|
||||||
|
file_put_contents($logFile, "[".date('c')."] worker start token={$token}\n", FILE_APPEND);
|
||||||
|
|
||||||
|
// Keep libzip temp files on same FS as final zip (prevents cross-device rename failures)
|
||||||
|
@mkdir($root, 0775, true);
|
||||||
|
@putenv('TMPDIR='.$root);
|
||||||
|
@ini_set('sys_temp_dir', $root);
|
||||||
|
|
||||||
|
// Small janitor: purge old tokens/logs (> 6h)
|
||||||
|
$now = time();
|
||||||
|
foreach (glob($tokDir.'/*.json') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||||
|
foreach (glob($logDir.'/WORKER-*.log') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||||
|
|
||||||
|
// Helpers to read/write the token file safely
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
|
||||||
|
$save = function() use (&$job, $tokFile) {
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
@clearstatcache(true, $tokFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
$touchPhase = function(string $phase) use (&$job, $save) {
|
||||||
|
$job['phase'] = $phase;
|
||||||
|
$save();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init timing
|
||||||
|
if (empty($job['startedAt'])) {
|
||||||
|
$job['startedAt'] = time();
|
||||||
|
}
|
||||||
|
$job['status'] = 'working';
|
||||||
|
$job['error'] = null;
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// Build the list of files to zip using the model (same validation FileRise uses)
|
||||||
|
try {
|
||||||
|
// Reuse FileModel’s validation by calling it but not keeping the zip; we’ll enumerate sizes here.
|
||||||
|
$folder = (string)($job['folder'] ?? 'root');
|
||||||
|
$names = (array)($job['files'] ?? []);
|
||||||
|
|
||||||
|
// Resolve folder path similarly to createZipArchive
|
||||||
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
|
if ($baseDir === false) {
|
||||||
|
throw new RuntimeException('Uploads directory not configured correctly.');
|
||||||
|
}
|
||||||
|
if (strtolower($folder) === 'root' || $folder === "") {
|
||||||
|
$folderPathReal = $baseDir;
|
||||||
|
} else {
|
||||||
|
if (strpos($folder, '..') !== false) throw new RuntimeException('Invalid folder name.');
|
||||||
|
$parts = explode('/', trim($folder, "/\\ "));
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||||
|
throw new RuntimeException('Invalid folder name.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
|
$folderPathReal = realpath($folderPath);
|
||||||
|
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||||
|
throw new RuntimeException('Folder not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect files (only regular files)
|
||||||
|
$filesToZip = [];
|
||||||
|
foreach ($names as $nm) {
|
||||||
|
$bn = basename(trim((string)$nm));
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $bn)) continue;
|
||||||
|
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
|
||||||
|
if (is_file($fp)) $filesToZip[] = $fp;
|
||||||
|
}
|
||||||
|
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
|
||||||
|
|
||||||
|
// Totals for progress
|
||||||
|
$filesTotal = count($filesToZip);
|
||||||
|
$bytesTotal = 0;
|
||||||
|
foreach ($filesToZip as $fp) {
|
||||||
|
$sz = @filesize($fp);
|
||||||
|
if ($sz !== false) $bytesTotal += (int)$sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job['filesTotal'] = $filesTotal;
|
||||||
|
$job['bytesTotal'] = $bytesTotal;
|
||||||
|
$job['filesDone'] = 0;
|
||||||
|
$job['bytesDone'] = 0;
|
||||||
|
$job['pct'] = 0;
|
||||||
|
$job['current'] = null;
|
||||||
|
$job['phase'] = 'zipping';
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// Create final zip path in META_DIR/ziptmp
|
||||||
|
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
||||||
|
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new RuntimeException('Could not create zip archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate files; report up to 98%
|
||||||
|
$bytesDone = 0;
|
||||||
|
$filesDone = 0;
|
||||||
|
foreach ($filesToZip as $fp) {
|
||||||
|
$bn = basename($fp);
|
||||||
|
$zip->addFile($fp, $bn);
|
||||||
|
|
||||||
|
$filesDone++;
|
||||||
|
$sz = @filesize($fp);
|
||||||
|
if ($sz !== false) $bytesDone += (int)$sz;
|
||||||
|
|
||||||
|
$job['filesDone'] = $filesDone;
|
||||||
|
$job['bytesDone'] = $bytesDone;
|
||||||
|
$job['current'] = $bn;
|
||||||
|
|
||||||
|
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
|
||||||
|
if ($pct < 0) $pct = 0;
|
||||||
|
if ($pct > 98) $pct = 98;
|
||||||
|
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
|
||||||
|
|
||||||
|
$save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalizing (this is where libzip writes & renames)
|
||||||
|
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
|
||||||
|
$job['phase'] = 'finalizing';
|
||||||
|
$job['finalizeAt'] = time();
|
||||||
|
|
||||||
|
// Publish selected totals for a truthful UI during finalizing,
|
||||||
|
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
|
||||||
|
$job['selectedFiles'] = $filesTotal;
|
||||||
|
$job['selectedBytes'] = $bytesTotal;
|
||||||
|
$job['filesDone'] = null;
|
||||||
|
$job['bytesDone'] = null;
|
||||||
|
$job['current'] = null;
|
||||||
|
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// ---- finalize the zip on disk ----
|
||||||
|
$ok = $zip->close();
|
||||||
|
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
|
||||||
|
|
||||||
|
if (!$ok || !is_file($zipPath)) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job['status'] = 'done';
|
||||||
|
$job['zipPath'] = $zipPath;
|
||||||
|
$job['pct'] = 100;
|
||||||
|
$job['phase'] = 'finalized';
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Worker exception: '.$e->getMessage();
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] exception: ".$e->getMessage()."\n", FILE_APPEND);
|
||||||
|
}
|
||||||
@@ -57,12 +57,26 @@ class AuthController
|
|||||||
$oidcAction = 'callback';
|
$oidcAction = 'callback';
|
||||||
}
|
}
|
||||||
if ($oidcAction) {
|
if ($oidcAction) {
|
||||||
$cfg = AdminModel::getConfig();
|
$cfg = AdminModel::getConfig();
|
||||||
|
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||||||
|
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||||||
|
// When configured as a public client (no secret), pass null, not an empty string.
|
||||||
|
if ($clientSecret === '') { $clientSecret = null; }
|
||||||
|
|
||||||
$oidc = new OpenIDConnectClient(
|
$oidc = new OpenIDConnectClient(
|
||||||
$cfg['oidc']['providerUrl'],
|
$cfg['oidc']['providerUrl'],
|
||||||
$cfg['oidc']['clientId'],
|
$clientId ?: null,
|
||||||
$cfg['oidc']['clientSecret']
|
$clientSecret
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
|
||||||
|
if (method_exists($oidc, 'setCodeChallengeMethod')) {
|
||||||
|
$oidc->setCodeChallengeMethod('S256');
|
||||||
|
}
|
||||||
|
// client_secret_post with Authelia using config.php
|
||||||
|
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||||
|
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
|
||||||
|
}
|
||||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||||
$oidc->addScope(['openid','profile','email']);
|
$oidc->addScope(['openid','profile','email']);
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,59 @@ class FileController
|
|||||||
return $ok ? null : "Forbidden: folder scope violation.";
|
return $ok ? null : "Forbidden: folder scope violation.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function spawnZipWorker(string $token, string $tokFile, string $logDir): array
|
||||||
|
{
|
||||||
|
$worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php');
|
||||||
|
if (!$worker || !is_file($worker)) {
|
||||||
|
return ['ok'=>false, 'error'=>'zip_worker.php not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a PHP CLI binary that actually works
|
||||||
|
$candidates = array_values(array_filter([
|
||||||
|
PHP_BINARY ?: null,
|
||||||
|
'/usr/local/bin/php',
|
||||||
|
'/usr/bin/php',
|
||||||
|
'/bin/php'
|
||||||
|
]));
|
||||||
|
$php = null;
|
||||||
|
foreach ($candidates as $bin) {
|
||||||
|
if (!$bin) continue;
|
||||||
|
$rc = 1;
|
||||||
|
@exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc);
|
||||||
|
if ($rc === 0) { $php = $bin; break; }
|
||||||
|
}
|
||||||
|
if (!$php) {
|
||||||
|
return ['ok'=>false, 'error'=>'No working php CLI found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log';
|
||||||
|
|
||||||
|
// Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process.
|
||||||
|
$tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||||
|
@mkdir($tmpDir, 0775, true);
|
||||||
|
|
||||||
|
// Build one sh -c string so env + nohup + echo $! are in the same shell
|
||||||
|
$cmdStr =
|
||||||
|
'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' .
|
||||||
|
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) .
|
||||||
|
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||||
|
|
||||||
|
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||||
|
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||||
|
|
||||||
|
// Persist spawn metadata into token (best-effort)
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
$job['spawn'] = [
|
||||||
|
'ts' => time(),
|
||||||
|
'php' => $php,
|
||||||
|
'pid' => $pid,
|
||||||
|
'log' => $logFile
|
||||||
|
];
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
|
return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID'];
|
||||||
|
}
|
||||||
|
|
||||||
// --- small helpers ---
|
// --- small helpers ---
|
||||||
private function _jsonStart(): void {
|
private function _jsonStart(): void {
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
@@ -665,99 +718,214 @@ public function deleteFiles()
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function downloadZip()
|
public function zipStatus()
|
||||||
{
|
{
|
||||||
try {
|
if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; }
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$this->_checkCsrf()) { http_response_code(400); echo "Bad CSRF"; return; }
|
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||||
if (!$this->_requireAuth()) { http_response_code(401); echo "Unauthorized"; return; }
|
if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; }
|
||||||
|
|
||||||
$data = $this->_readJsonBody();
|
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||||
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; }
|
||||||
http_response_code(400); echo "Invalid input."; return;
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
}
|
if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; }
|
||||||
|
|
||||||
$folder = $this->_normalizeFolder($data['folder']);
|
$ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']);
|
||||||
$files = $data['files'];
|
|
||||||
if (!$this->_validFolder($folder)) { http_response_code(400); echo "Invalid folder name."; return; }
|
$out = [
|
||||||
|
'status' => $job['status'] ?? 'unknown',
|
||||||
$username = $_SESSION['username'] ?? '';
|
'error' => $job['error'] ?? null,
|
||||||
$perms = $this->loadPerms($username);
|
'ready' => $ready,
|
||||||
|
// progress (if present)
|
||||||
// Optional zip gate by account flag
|
'pct' => $job['pct'] ?? null,
|
||||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
'filesDone' => $job['filesDone'] ?? null,
|
||||||
http_response_code(403); echo "ZIP downloads are not allowed for your account."; return;
|
'filesTotal' => $job['filesTotal'] ?? null,
|
||||||
}
|
'bytesDone' => $job['bytesDone'] ?? null,
|
||||||
|
'bytesTotal' => $job['bytesTotal'] ?? null,
|
||||||
$ignoreOwnership = $this->isAdmin($perms)
|
'current' => $job['current'] ?? null,
|
||||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
'phase' => $job['phase'] ?? null,
|
||||||
|
// timing (always include for UI)
|
||||||
// Ancestor-owner counts as full view
|
'startedAt' => $job['startedAt'] ?? null,
|
||||||
$fullView = $ignoreOwnership
|
'finalizeAt' => $job['finalizeAt'] ?? null,
|
||||||
|| ACL::canRead($username, $perms, $folder)
|
];
|
||||||
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
|
||||||
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
if ($ready) {
|
||||||
|
$out['size'] = @filesize($job['zipPath']) ?: null;
|
||||||
if (!$fullView && !$ownOnly) { http_response_code(403); echo "Forbidden: no view access to this folder."; return; }
|
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
|
||||||
|
}
|
||||||
if ($ownOnly) {
|
|
||||||
$meta = $this->loadFolderMetadata($folder);
|
header('Content-Type: application/json');
|
||||||
foreach ($files as $f) {
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
$bn = basename((string)$f);
|
header('Pragma: no-cache');
|
||||||
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
header('Expires: 0');
|
||||||
http_response_code(403); echo "Forbidden: you are not the owner of '{$bn}'."; return;
|
echo json_encode($out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function downloadZipFile()
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; }
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||||
|
if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; }
|
||||||
|
|
||||||
|
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||||
|
if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; }
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
@unlink($tokFile); // one-shot token
|
||||||
|
|
||||||
|
if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; }
|
||||||
|
$zip = (string)($job['zipPath'] ?? '');
|
||||||
|
$zipReal = realpath($zip);
|
||||||
|
$root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp');
|
||||||
|
if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; }
|
||||||
|
|
||||||
|
@session_write_close();
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1');
|
||||||
|
@ini_set('zlib.output_compression','0');
|
||||||
|
@ini_set('output_buffering','off');
|
||||||
|
while (ob_get_level()>0) @ob_end_clean();
|
||||||
|
|
||||||
|
@clearstatcache(true, $zipReal);
|
||||||
|
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip';
|
||||||
|
if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip';
|
||||||
|
$size = (int)@filesize($zipReal);
|
||||||
|
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Content-Disposition: attachment; filename="'.$name.'"');
|
||||||
|
if ($size>0) header('Content-Length: '.$size);
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
|
readfile($zipReal);
|
||||||
|
@unlink($zipReal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadZip()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; }
|
||||||
|
if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); return; }
|
||||||
|
|
||||||
|
$data = $this->_readJsonBody();
|
||||||
|
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
||||||
|
$this->_jsonOut(["error" => "Invalid input."], 400); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = $this->_normalizeFolder($data['folder']);
|
||||||
|
$files = $data['files'];
|
||||||
|
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
|
||||||
|
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$perms = $this->loadPerms($username);
|
||||||
|
|
||||||
|
// Optional zip gate by account flag
|
||||||
|
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||||
|
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ignoreOwnership = $this->isAdmin($perms)
|
||||||
|
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||||
|
|
||||||
|
// Ancestor-owner counts as full view
|
||||||
|
$fullView = $ignoreOwnership
|
||||||
|
|| ACL::canRead($username, $perms, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||||
|
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||||
|
|
||||||
|
if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; }
|
||||||
|
|
||||||
|
// If own-only, ensure all files are owned by the user
|
||||||
|
if ($ownOnly) {
|
||||||
|
$meta = $this->loadFolderMetadata($folder);
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$bn = basename((string)$f);
|
||||||
|
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
||||||
|
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = FileModel::createZipArchive($folder, $files);
|
|
||||||
if (isset($result['error'])) { http_response_code(400); echo $result['error']; return; }
|
|
||||||
|
|
||||||
$zipPath = $result['zipPath'] ?? null;
|
|
||||||
if (!$zipPath || !is_file($zipPath)) { http_response_code(500); echo "ZIP archive not found."; return; }
|
|
||||||
|
|
||||||
// ---- Clean binary stream setup ----
|
|
||||||
@session_write_close();
|
|
||||||
@set_time_limit(0);
|
|
||||||
@ignore_user_abort(true);
|
|
||||||
if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); }
|
|
||||||
@ini_set('zlib.output_compression', '0');
|
|
||||||
@ini_set('output_buffering', 'off');
|
|
||||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
|
||||||
|
|
||||||
@clearstatcache(true, $zipPath);
|
|
||||||
$size = (int)@filesize($zipPath);
|
|
||||||
|
|
||||||
header('X-Accel-Buffering: no');
|
|
||||||
header_remove('Content-Type');
|
|
||||||
header('Content-Type: application/zip');
|
|
||||||
// Client sets the final name via a.download in your JS; server can be generic
|
|
||||||
header('Content-Disposition: attachment; filename="files.zip"');
|
|
||||||
if ($size > 0) header('Content-Length: ' . $size);
|
|
||||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
|
||||||
header('Pragma: no-cache');
|
|
||||||
|
|
||||||
$fp = fopen($zipPath, 'rb');
|
|
||||||
if ($fp === false) { http_response_code(500); echo "Failed to open ZIP."; return; }
|
|
||||||
|
|
||||||
$chunk = 1048576; // 1 MiB
|
|
||||||
while (!feof($fp)) {
|
|
||||||
$buf = fread($fp, $chunk);
|
|
||||||
if ($buf === false) break;
|
|
||||||
echo $buf;
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
fclose($fp);
|
|
||||||
@unlink($zipPath);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
|
||||||
if (!headers_sent()) http_response_code(500);
|
|
||||||
echo "Internal server error while preparing ZIP.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||||
|
$tokDir = $root . DIRECTORY_SEPARATOR . '.tokens';
|
||||||
|
$logDir = $root . DIRECTORY_SEPARATOR . '.logs';
|
||||||
|
if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true);
|
||||||
|
if (!is_dir($logDir)) @mkdir($logDir, 0700, true);
|
||||||
|
@chmod($tokDir, 0700);
|
||||||
|
@chmod($logDir, 0700);
|
||||||
|
if (!is_dir($tokDir) || !is_writable($tokDir)) {
|
||||||
|
$this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light janitor: purge old tokens/logs > 6h (best-effort)
|
||||||
|
$now = time();
|
||||||
|
foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) {
|
||||||
|
if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); }
|
||||||
|
}
|
||||||
|
foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) {
|
||||||
|
if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user and global caps (simple anti-DoS)
|
||||||
|
$perUserCap = 2; // tweak if desired
|
||||||
|
$globalCap = 8; // tweak if desired
|
||||||
|
|
||||||
|
$tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||||
|
$mine = 0; $all = 0;
|
||||||
|
foreach ($tokens as $tf) {
|
||||||
|
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
|
||||||
|
$st = $job['status'] ?? 'unknown';
|
||||||
|
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
|
||||||
|
$all++;
|
||||||
|
if (($job['user'] ?? '') === $username) $mine++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; }
|
||||||
|
if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; }
|
||||||
|
|
||||||
|
// Create job token
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json';
|
||||||
|
$job = [
|
||||||
|
'user' => $username,
|
||||||
|
'folder' => $folder,
|
||||||
|
'files' => array_values($files),
|
||||||
|
'status' => 'queued',
|
||||||
|
'ctime' => time(),
|
||||||
|
'startedAt' => null,
|
||||||
|
'finalizeAt' => null,
|
||||||
|
'zipPath' => null,
|
||||||
|
'error' => null
|
||||||
|
];
|
||||||
|
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
|
$this->_jsonOut(["error"=>"Failed to create zip job."],500); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust spawn (detect php CLI, log, record PID)
|
||||||
|
$spawn = $this->spawnZipWorker($token, $tokFile, $logDir);
|
||||||
|
if (!$spawn['ok']) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Spawn failed: '.$spawn['error'];
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
$this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->_jsonOut([
|
||||||
|
'ok' => true,
|
||||||
|
'token' => $token,
|
||||||
|
'status' => 'queued',
|
||||||
|
'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token),
|
||||||
|
'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token)
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||||
|
$this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function extractZip()
|
public function extractZip()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ private const OO_SUPPORTED_EXTS = [
|
|||||||
'ppt','pptx','odp',
|
'ppt','pptx','odp',
|
||||||
'pdf'
|
'pdf'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
|
||||||
|
private function effectiveFileOriginForDocs(): string
|
||||||
|
{
|
||||||
|
$cfg = AdminModel::getConfig();
|
||||||
|
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||||
|
|
||||||
|
// 1) explicit constant
|
||||||
|
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
|
||||||
|
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
|
||||||
|
}
|
||||||
|
// 2) admin.json setting
|
||||||
|
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
|
||||||
|
|
||||||
|
// 3) fallback: whatever the public sees (may hairpin, but still works)
|
||||||
|
return $this->effectivePublicOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
// Never editable via OO (we’ll always set edit=false for these)
|
// Never editable via OO (we’ll always set edit=false for these)
|
||||||
private const OO_NEVER_EDIT = ['pdf'];
|
private const OO_NEVER_EDIT = ['pdf'];
|
||||||
@@ -127,117 +144,119 @@ private function ooLog(string $level, string $msg): void
|
|||||||
|
|
||||||
/** GET /api/onlyoffice/status.php */
|
/** GET /api/onlyoffice/status.php */
|
||||||
public function status(): void
|
public function status(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
$enabled = $this->effectiveEnabled();
|
$enabled = $this->effectiveEnabled();
|
||||||
$docsOrig = $this->effectiveDocsOrigin();
|
$docsOrig = $this->effectiveDocsOrigin();
|
||||||
$secret = $this->effectiveSecret();
|
$secret = $this->effectiveSecret();
|
||||||
|
|
||||||
// Must have docs origin and secret to actually function
|
// Must have docs origin and secret to actually function
|
||||||
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
||||||
|
|
||||||
$exts = self::OO_SUPPORTED_EXTS;
|
$exts = self::OO_SUPPORTED_EXTS;
|
||||||
// If you want the extras:
|
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
||||||
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
|
||||||
|
echo json_encode([
|
||||||
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES);
|
'enabled' => (bool)$enabled,
|
||||||
}
|
'exts' => $exts,
|
||||||
|
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
|
||||||
|
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/onlyoffice/config.php?folder=...&file=... */
|
/** GET /api/onlyoffice/config.php?folder=...&file=... */
|
||||||
public function config(): void
|
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
|
||||||
{
|
public function config(): void
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
{
|
||||||
header('Cache-Control: no-store');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
@session_start();
|
@session_start();
|
||||||
$user = $_SESSION['username'] ?? 'anonymous';
|
$user = $_SESSION['username'] ?? 'anonymous';
|
||||||
$perms = [];
|
$perms = [];
|
||||||
$isAdmin = \ACL::isAdmin($perms);
|
$isAdmin = \ACL::isAdmin($perms);
|
||||||
|
|
||||||
// Effective toggles
|
$enabled = $this->effectiveEnabled();
|
||||||
$enabled = $this->effectiveEnabled();
|
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
||||||
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
$secret = $this->effectiveSecret();
|
||||||
$secret = $this->effectiveSecret();
|
|
||||||
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
|
|
||||||
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
|
|
||||||
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
|
|
||||||
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
|
|
||||||
|
|
||||||
// Inputs
|
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
|
||||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
|
||||||
$file = basename((string)($_GET['file'] ?? ''));
|
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
|
||||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
|
||||||
|
|
||||||
// ACL
|
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
$file = basename((string)($_GET['file'] ?? ''));
|
||||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||||
|
|
||||||
// Path
|
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
|
||||||
$abs = realpath($base . $rel . $file);
|
|
||||||
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
|
|
||||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
|
|
||||||
|
|
||||||
// Public origin
|
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||||
$publicOrigin = $this->effectivePublicOrigin();
|
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||||
|
$abs = realpath($base . $rel . $file);
|
||||||
|
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
|
||||||
|
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
|
||||||
|
|
||||||
// Signed download
|
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
|
||||||
$exp = time() + 10*60;
|
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
|
||||||
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
|
|
||||||
$sig = hash_hmac('sha256', $data, $secret, true);
|
|
||||||
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
|
|
||||||
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
|
||||||
|
|
||||||
// Callback
|
$exp = time() + 10*60;
|
||||||
$cbExp = time() + 10*60;
|
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
|
||||||
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
$sig = hash_hmac('sha256', $data, $secret, true);
|
||||||
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php'
|
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
|
||||||
. '?folder=' . rawurlencode($folder)
|
$fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
||||||
. '&file=' . rawurlencode($file)
|
|
||||||
. '&exp=' . $cbExp
|
|
||||||
. '&sig=' . $cbSig;
|
|
||||||
|
|
||||||
// Doc type & key
|
$cbExp = time() + 10*60;
|
||||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
||||||
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
|
||||||
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
. '?folder=' . rawurlencode($folder)
|
||||||
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
|
. '&file=' . rawurlencode($file)
|
||||||
|
. '&exp=' . $cbExp
|
||||||
|
. '&sig=' . $cbSig;
|
||||||
|
|
||||||
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
||||||
|
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
||||||
|
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
||||||
|
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
|
||||||
|
|
||||||
$cfgOut = [
|
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
||||||
'document' => [
|
|
||||||
'fileType' => $ext,
|
|
||||||
'key' => $key,
|
|
||||||
'title' => $file,
|
|
||||||
'url' => $fileUrl,
|
|
||||||
'permissions' => [
|
|
||||||
'download' => true,
|
|
||||||
'print' => true,
|
|
||||||
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'documentType' => $docType,
|
|
||||||
'editorConfig' => [
|
|
||||||
'callbackUrl' => $callbackUrl,
|
|
||||||
'user' => ['id'=>$user, 'name'=>$user],
|
|
||||||
'lang' => 'en',
|
|
||||||
],
|
|
||||||
'type' => 'desktop',
|
|
||||||
];
|
|
||||||
|
|
||||||
// JWT sign cfg
|
$cfgOut = [
|
||||||
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
|
'document' => [
|
||||||
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
'fileType' => $ext,
|
||||||
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
'key' => $key,
|
||||||
$cfgOut['token'] = "$h.$p.$s";
|
'title' => $file,
|
||||||
$cfgOut['docs_api_js'] = $docsApiJs;
|
'url' => $fileUrl,
|
||||||
|
'permissions' => [
|
||||||
|
'download' => true,
|
||||||
|
'print' => true,
|
||||||
|
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'documentType' => $docType,
|
||||||
|
'editorConfig' => [
|
||||||
|
'callbackUrl' => $callbackUrl,
|
||||||
|
'user' => ['id'=>$user, 'name'=>$user],
|
||||||
|
'lang' => 'en',
|
||||||
|
],
|
||||||
|
'type' => 'desktop',
|
||||||
|
];
|
||||||
|
|
||||||
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
// JWT sign cfg
|
||||||
}
|
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
|
||||||
|
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
||||||
|
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
||||||
|
$cfgOut['token'] = "$h.$p.$s";
|
||||||
|
|
||||||
|
// expose to client for preconnect/script load
|
||||||
|
$cfgOut['docs_api_js'] = $docsApiJs;
|
||||||
|
$cfgOut['documentServerOrigin'] = $docsOrigin;
|
||||||
|
|
||||||
|
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
||||||
public function callback(): void
|
public function callback(): void
|
||||||
@@ -343,41 +362,52 @@ private function ooLog(string $level, string $msg): void
|
|||||||
|
|
||||||
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
||||||
public function signedDownload(): void
|
public function signedDownload(): void
|
||||||
{
|
{
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
$secret = $this->effectiveSecret();
|
$secret = $this->effectiveSecret();
|
||||||
if ($secret === '') { http_response_code(403); return; }
|
if ($secret === '') { http_response_code(403); return; }
|
||||||
|
|
||||||
$tok = $_GET['tok'] ?? '';
|
$tok = $_GET['tok'] ?? '';
|
||||||
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
|
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
|
||||||
[$b64data, $b64sig] = explode('.', $tok, 2);
|
[$b64data, $b64sig] = explode('.', $tok, 2);
|
||||||
$data = $this->b64uDec($b64data);
|
$data = $this->b64uDec($b64data);
|
||||||
$sig = $this->b64uDec($b64sig);
|
$sig = $this->b64uDec($b64sig);
|
||||||
if ($data === false || $sig === false) { http_response_code(400); return; }
|
if ($data === false || $sig === false) { http_response_code(400); return; }
|
||||||
|
|
||||||
$calc = hash_hmac('sha256', $data, $secret, true);
|
$calc = hash_hmac('sha256', $data, $secret, true);
|
||||||
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
|
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
|
||||||
|
|
||||||
$payload = json_decode($data, true);
|
$payload = json_decode($data, true);
|
||||||
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
|
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
|
||||||
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
|
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
|
||||||
|
|
||||||
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
|
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
|
||||||
if ($folder === '' || $folder === 'root') $folder = 'root';
|
if ($folder === '' || $folder === 'root') $folder = 'root';
|
||||||
$file = basename((string)$payload['n']);
|
$file = basename((string)$payload['n']);
|
||||||
|
|
||||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||||
$abs = realpath($base . $rel . $file);
|
$abs = realpath($base . $rel . $file);
|
||||||
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
||||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
||||||
|
|
||||||
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
// Common headers
|
||||||
header('Content-Type: '.$mime);
|
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
||||||
header('Content-Length: '.filesize($abs));
|
$len = filesize($abs);
|
||||||
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
header('Content-Type: '.$mime);
|
||||||
readfile($abs);
|
header('Content-Length: '.$len);
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
||||||
|
header('Accept-Ranges: none'); // OO doesn’t require ranges; avoids partial edge-cases
|
||||||
|
|
||||||
|
// ---- Key change: for HEAD, do NOT read the file ----
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
|
||||||
|
// send headers only; no body
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET → stream the file
|
||||||
|
readfile($abs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
390
src/lib/ACL.php
@@ -10,23 +10,38 @@ class ACL
|
|||||||
private static $path = null;
|
private static $path = null;
|
||||||
|
|
||||||
private const BUCKETS = [
|
private const BUCKETS = [
|
||||||
'owners','read','write','share','read_own',
|
'owners',
|
||||||
'create','upload','edit','rename','copy','move','delete','extract',
|
'read',
|
||||||
'share_file','share_folder'
|
'write',
|
||||||
|
'share',
|
||||||
|
'read_own',
|
||||||
|
'create',
|
||||||
|
'upload',
|
||||||
|
'edit',
|
||||||
|
'rename',
|
||||||
|
'copy',
|
||||||
|
'move',
|
||||||
|
'delete',
|
||||||
|
'extract',
|
||||||
|
'share_file',
|
||||||
|
'share_folder'
|
||||||
];
|
];
|
||||||
|
|
||||||
private static function path(): string {
|
private static function path(): string
|
||||||
|
{
|
||||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
return self::$path;
|
return self::$path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function normalizeFolder(string $f): string {
|
public static function normalizeFolder(string $f): string
|
||||||
|
{
|
||||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||||
if ($f === '' || $f === 'root') return 'root';
|
if ($f === '' || $f === 'root') return 'root';
|
||||||
return $f;
|
return $f;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function purgeUser(string $user): bool {
|
public static function purgeUser(string $user): bool
|
||||||
|
{
|
||||||
$user = (string)$user;
|
$user = (string)$user;
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$changed = false;
|
$changed = false;
|
||||||
@@ -41,49 +56,107 @@ class ACL
|
|||||||
return $changed ? self::save($acl) : true;
|
return $changed ? self::save($acl) : true;
|
||||||
}
|
}
|
||||||
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||||
{
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||||
|
|
||||||
$folder = trim($folder, "/\\ ");
|
$folder = trim($folder, "/\\ ");
|
||||||
if ($folder === '' || $folder === 'root') return false;
|
if ($folder === '' || $folder === 'root') return false;
|
||||||
|
|
||||||
$parts = explode('/', $folder);
|
$parts = explode('/', $folder);
|
||||||
while (count($parts) > 1) {
|
while (count($parts) > 1) {
|
||||||
array_pop($parts);
|
array_pop($parts);
|
||||||
$parent = implode('/', $parts);
|
$parent = implode('/', $parts);
|
||||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function migrateSubtree(string $source, string $target): array
|
||||||
|
{
|
||||||
|
// PHP <8 polyfill
|
||||||
|
if (!function_exists('str_starts_with')) {
|
||||||
|
function str_starts_with(string $h, string $n): bool
|
||||||
|
{
|
||||||
|
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = self::normalizeFolder($source);
|
||||||
|
$dst = self::normalizeFolder($target);
|
||||||
|
if ($src === 'root') return ['changed' => false, 'moved' => 0];
|
||||||
|
|
||||||
|
$file = self::path(); // e.g. META_DIR/folder_acl.json
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||||
|
if (!is_array($map)) $map = [];
|
||||||
|
|
||||||
|
$prefix = $src;
|
||||||
|
$needle = $src . '/';
|
||||||
|
|
||||||
|
$new = $map;
|
||||||
|
$changed = false;
|
||||||
|
$moved = 0;
|
||||||
|
|
||||||
|
foreach ($map as $key => $entry) {
|
||||||
|
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
|
||||||
|
if (!$isMatch) continue;
|
||||||
|
|
||||||
|
unset($new[$key]);
|
||||||
|
|
||||||
|
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
|
||||||
|
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
|
||||||
|
|
||||||
|
// keep only known buckets (defensive)
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$clean = [];
|
||||||
|
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
|
||||||
|
$entry = $clean ?: $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwrite any existing entry at destination path (safer than union)
|
||||||
|
$new[$newKey] = $entry;
|
||||||
|
$changed = true;
|
||||||
|
$moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
@chmod($file, 0664);
|
||||||
|
self::$cache = $new; // keep in-process cache fresh if you use it
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['changed' => $changed, 'moved' => $moved];
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||||
{
|
{
|
||||||
$old = self::normalizeFolder($oldFolder);
|
$old = self::normalizeFolder($oldFolder);
|
||||||
$new = self::normalizeFolder($newFolder);
|
$new = self::normalizeFolder($newFolder);
|
||||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||||
|
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||||
|
|
||||||
$rebased = [];
|
$rebased = [];
|
||||||
foreach ($acl['folders'] as $k => $rec) {
|
foreach ($acl['folders'] as $k => $rec) {
|
||||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||||
$suffix = substr($k, strlen($old));
|
$suffix = substr($k, strlen($old));
|
||||||
$suffix = ltrim((string)$suffix, '/');
|
$suffix = ltrim((string)$suffix, '/');
|
||||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||||
$rebased[$newKey] = $rec;
|
$rebased[$newKey] = $rec;
|
||||||
} else {
|
} else {
|
||||||
$rebased[$k] = $rec;
|
$rebased[$k] = $rec;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$acl['folders'] = $rebased;
|
||||||
|
self::save($acl);
|
||||||
}
|
}
|
||||||
$acl['folders'] = $rebased;
|
|
||||||
self::save($acl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function loadFresh(): array {
|
private static function loadFresh(): array
|
||||||
|
{
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
@mkdir(dirname($path), 0755, true);
|
@mkdir(dirname($path), 0755, true);
|
||||||
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
'read' => ['admin'],
|
'read' => ['admin'],
|
||||||
'write' => ['admin'],
|
'write' => ['admin'],
|
||||||
'share' => ['admin'],
|
'share' => ['admin'],
|
||||||
'read_own'=> [],
|
'read_own' => [],
|
||||||
'create' => [],
|
'create' => [],
|
||||||
'upload' => [],
|
'upload' => [],
|
||||||
'edit' => [],
|
'edit' => [],
|
||||||
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
|
|
||||||
$healed = false;
|
$healed = false;
|
||||||
foreach ($data['folders'] as $folder => &$rec) {
|
foreach ($data['folders'] as $folder => &$rec) {
|
||||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
if (!is_array($rec)) {
|
||||||
|
$rec = [];
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
$v = $rec[$k] ?? [];
|
$v = $rec[$k] ?? [];
|
||||||
if (!is_array($v)) { $v = []; $healed = true; }
|
if (!is_array($v)) {
|
||||||
|
$v = [];
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
$v = array_values(array_unique(array_map('strval', $v)));
|
$v = array_values(array_unique(array_map('strval', $v)));
|
||||||
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
if (($rec[$k] ?? null) !== $v) {
|
||||||
|
$rec[$k] = $v;
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($rec);
|
unset($rec);
|
||||||
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function save(array $acl): bool {
|
private static function save(array $acl): bool
|
||||||
|
{
|
||||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||||
if ($ok) self::$cache = $acl;
|
if ($ok) self::$cache = $acl;
|
||||||
return $ok;
|
return $ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function listFor(string $folder, string $key): array {
|
private static function listFor(string $folder, string $key): array
|
||||||
|
{
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$f = $acl['folders'][$folder] ?? null;
|
$f = $acl['folders'][$folder] ?? null;
|
||||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
if (!isset($acl['folders'][$folder])) {
|
if (!isset($acl['folders'][$folder])) {
|
||||||
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isAdmin(array $perms = []): bool {
|
public static function isAdmin(array $perms = []): bool
|
||||||
|
{
|
||||||
if (!empty($_SESSION['isAdmin'])) return true;
|
if (!empty($_SESSION['isAdmin'])) return true;
|
||||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
if (
|
||||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||||
|
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
public static function hasGrant(string $user, string $folder, string $cap): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||||
$arr = self::listFor($folder, $capKey);
|
$arr = self::listFor($folder, $capKey);
|
||||||
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
public static function isOwner(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners');
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
public static function canManage(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
return self::isOwner($user, $perms, $folder);
|
return self::isOwner($user, $perms, $folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
public static function canRead(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'read');
|
|| self::hasGrant($user, $folder, 'read');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
public static function canReadOwn(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
if (self::canRead($user, $perms, $folder)) return true;
|
if (self::canRead($user, $perms, $folder)) return true;
|
||||||
return self::hasGrant($user, $folder, 'read_own');
|
return self::hasGrant($user, $folder, 'read_own');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
public static function canWrite(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
public static function canShare(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy-only explicit (to avoid breaking existing callers)
|
// Legacy-only explicit (to avoid breaking existing callers)
|
||||||
public static function explicit(string $folder): array {
|
public static function explicit(string $folder): array
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$rec = $acl['folders'][$folder] ?? [];
|
$rec = $acl['folders'][$folder] ?? [];
|
||||||
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New: full explicit including granular
|
// New: full explicit including granular
|
||||||
public static function explicitAll(string $folder): array {
|
public static function explicitAll(string $folder): array
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$rec = $acl['folders'][$folder] ?? [];
|
$rec = $acl['folders'][$folder] ?? [];
|
||||||
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
||||||
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
return self::save($acl);
|
return self::save($acl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
public static function applyUserGrantsAtomic(string $user, array $grants): array
|
||||||
|
{
|
||||||
$user = (string)$user;
|
$user = (string)$user;
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
|
|
||||||
$fh = @fopen($path, 'c+');
|
$fh = @fopen($path, 'c+');
|
||||||
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
if (!flock($fh, LOCK_EX)) {
|
||||||
|
fclose($fh);
|
||||||
|
throw new RuntimeException('Cannot lock ACL storage');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$raw = stream_get_contents($fh);
|
$raw = stream_get_contents($fh);
|
||||||
if ($raw === false) $raw = '';
|
if ($raw === false) $raw = '';
|
||||||
$acl = json_decode($raw, true);
|
$acl = json_decode($raw, true);
|
||||||
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||||
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||||
|
|
||||||
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
foreach ($grants as $folder => $caps) {
|
foreach ($grants as $folder => $caps) {
|
||||||
$ff = self::normalizeFolder((string)$folder);
|
$ff = self::normalizeFolder((string)$folder);
|
||||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||||
$rec =& $acl['folders'][$ff];
|
$rec = &$acl['folders'][$ff];
|
||||||
|
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||||
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||||
|
|
||||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
if ($m) {
|
||||||
|
$v = true;
|
||||||
|
$w = true;
|
||||||
|
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
|
||||||
|
}
|
||||||
if ($u && !$v && !$vo) $vo = true;
|
if ($u && !$v && !$vo) $vo = true;
|
||||||
//if ($s && !$v) $v = true;
|
//if ($s && !$v) $v = true;
|
||||||
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
if ($w) {
|
||||||
|
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($m) $rec['owners'][] = $user;
|
if ($m) $rec['owners'][] = $user;
|
||||||
if ($v) $rec['read'][] = $user;
|
if ($v) $rec['read'][] = $user;
|
||||||
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
if ($dl) $rec['delete'][] = $user;
|
if ($dl) $rec['delete'][] = $user;
|
||||||
if ($ex) $rec['extract'][] = $user;
|
if ($ex) $rec['extract'][] = $user;
|
||||||
if ($sf) $rec['share_file'][] = $user;
|
if ($sf) $rec['share_file'][] = $user;
|
||||||
if ($sfo)$rec['share_folder'][] = $user;
|
if ($sfo) $rec['share_folder'][] = $user;
|
||||||
|
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||||
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Granular write family -----------------------------------------------
|
// --- Granular write family -----------------------------------------------
|
||||||
|
|
||||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
public static function canCreate(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'create')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'create')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
public static function canCreateFolder(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
// Only owners/managers can create subfolders under $folder
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners');
|
// Only owners/managers can create subfolders under $folder
|
||||||
}
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
public static function canUpload(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'upload')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'upload')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
public static function canEdit(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'edit')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'edit')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
public static function canRename(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'rename')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'rename')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
public static function canCopy(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'copy')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'copy')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
public static function canMove(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
if (self::isAdmin($perms)) return true;
|
||||||
}
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
public static function canMoveFolder(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
if (self::isAdmin($perms)) return true;
|
||||||
}
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
public static function canDelete(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'delete')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'delete')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canExtract(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'extract')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
|
||||||
$folder = self::normalizeFolder($folder);
|
|
||||||
if (self::isAdmin($perms)) return true;
|
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
|
||||||
|| self::hasGrant($user, $folder, 'extract')
|
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sharing: files use share, folders require share + full-view. */
|
/** Sharing: files use share, folders require share + full-view. */
|
||||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
public static function canShareFile(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
}
|
}
|
||||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
public static function canShareFolder(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
|
|||||||
@@ -557,13 +557,13 @@ class FileModel {
|
|||||||
* @return array An associative array with either an "error" key or a "zipPath" key.
|
* @return array An associative array with either an "error" key or a "zipPath" key.
|
||||||
*/
|
*/
|
||||||
public static function createZipArchive($folder, $files) {
|
public static function createZipArchive($folder, $files) {
|
||||||
|
// Purge old temp zips > 6h (best-effort)
|
||||||
// (optional) purge old temp zips > 6h
|
|
||||||
$zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
$zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||||
$now = time();
|
$now = time();
|
||||||
foreach (glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: [] as $zp) {
|
foreach ((glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) {
|
||||||
if (is_file($zp) && ($now - @filemtime($zp)) > 21600) { @unlink($zp); }
|
if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) { @unlink($zp); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and validate target folder
|
// Normalize and validate target folder
|
||||||
$folder = trim((string)$folder) ?: 'root';
|
$folder = trim((string)$folder) ?: 'root';
|
||||||
$baseDir = realpath(UPLOAD_DIR);
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
@@ -574,7 +574,6 @@ class FileModel {
|
|||||||
if (strtolower($folder) === 'root' || $folder === "") {
|
if (strtolower($folder) === 'root' || $folder === "") {
|
||||||
$folderPathReal = $baseDir;
|
$folderPathReal = $baseDir;
|
||||||
} else {
|
} else {
|
||||||
// Prevent traversal and validate each segment against folder regex
|
|
||||||
if (strpos($folder, '..') !== false) {
|
if (strpos($folder, '..') !== false) {
|
||||||
return ["error" => "Invalid folder name."];
|
return ["error" => "Invalid folder name."];
|
||||||
}
|
}
|
||||||
@@ -599,6 +598,10 @@ class FileModel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
|
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
|
||||||
|
// Skip symlinks (avoid archiving outside targets via links)
|
||||||
|
if (is_link($fullPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (is_file($fullPath)) {
|
if (is_file($fullPath)) {
|
||||||
$filesToZip[] = $fullPath;
|
$filesToZip[] = $fullPath;
|
||||||
}
|
}
|
||||||
@@ -609,9 +612,7 @@ class FileModel {
|
|||||||
|
|
||||||
// Workspace on the big disk: META_DIR/ziptmp
|
// Workspace on the big disk: META_DIR/ziptmp
|
||||||
$work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
$work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||||
if (!is_dir($work)) {
|
if (!is_dir($work)) { @mkdir($work, 0775, true); }
|
||||||
@mkdir($work, 0775, true);
|
|
||||||
}
|
|
||||||
if (!is_dir($work) || !is_writable($work)) {
|
if (!is_dir($work) || !is_writable($work)) {
|
||||||
return ["error" => "ZIP temp dir not writable: " . $work];
|
return ["error" => "ZIP temp dir not writable: " . $work];
|
||||||
}
|
}
|
||||||
@@ -633,7 +634,7 @@ class FileModel {
|
|||||||
|
|
||||||
@set_time_limit(0);
|
@set_time_limit(0);
|
||||||
|
|
||||||
// Create the ZIP path inside META_DIR/ziptmp
|
// Create the ZIP path inside META_DIR/ziptmp (libzip temp stays on same FS)
|
||||||
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
||||||
$zipPath = $work . DIRECTORY_SEPARATOR . $zipName;
|
$zipPath = $work . DIRECTORY_SEPARATOR . $zipName;
|
||||||
|
|
||||||
@@ -643,7 +644,7 @@ class FileModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($filesToZip as $filePath) {
|
foreach ($filesToZip as $filePath) {
|
||||||
// Add using basename at the root of the zip (matches your current behavior)
|
// Add using basename at the root of the zip (matches current behavior)
|
||||||
$zip->addFile($filePath, basename($filePath));
|
$zip->addFile($filePath, basename($filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
src/models/FolderMeta.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
// src/models/FolderMeta.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||||
|
|
||||||
|
class FolderMeta
|
||||||
|
{
|
||||||
|
private static function path(): string {
|
||||||
|
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeFolder(string $folder): string {
|
||||||
|
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
|
||||||
|
return ($f === '' || $f === 'root') ? 'root' : $f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
|
||||||
|
public static function normalizeHex(?string $hex): ?string {
|
||||||
|
if ($hex === null || $hex === '') return null;
|
||||||
|
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid color hex');
|
||||||
|
}
|
||||||
|
if (strlen($hex) === 4) {
|
||||||
|
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
|
||||||
|
}
|
||||||
|
return strtoupper($hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read full map from disk */
|
||||||
|
public static function getMap(): array {
|
||||||
|
$file = self::path();
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||||
|
return is_array($map) ? $map : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write full map to disk (atomic-ish) */
|
||||||
|
private static function writeMap(array $map): void {
|
||||||
|
$file = self::path();
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||||
|
$tmp = $file . '.tmp';
|
||||||
|
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
@rename($tmp, $file);
|
||||||
|
@chmod($file, 0664);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set or clear a color for one folder */
|
||||||
|
public static function setColor(string $folder, ?string $hex): array {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$hex = self::normalizeHex($hex);
|
||||||
|
$map = self::getMap();
|
||||||
|
|
||||||
|
if ($hex === null) unset($map[$folder]);
|
||||||
|
else $map[$folder] = $hex;
|
||||||
|
|
||||||
|
self::writeMap($map);
|
||||||
|
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migrate color entries for a whole subtree (used by move/rename) */
|
||||||
|
public static function migrateSubtree(string $source, string $target): array {
|
||||||
|
$src = self::normalizeFolder($source);
|
||||||
|
$dst = self::normalizeFolder($target);
|
||||||
|
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
|
||||||
|
|
||||||
|
$map = self::getMap();
|
||||||
|
if (!$map) return ['changed'=>false, 'moved'=>0];
|
||||||
|
|
||||||
|
$new = $map;
|
||||||
|
$moved = 0;
|
||||||
|
|
||||||
|
foreach ($map as $key => $hex) {
|
||||||
|
$isSelf = ($key === $src);
|
||||||
|
$isSub = str_starts_with($key.'/', $src.'/');
|
||||||
|
if (!$isSelf && !$isSub) continue;
|
||||||
|
|
||||||
|
unset($new[$key]);
|
||||||
|
$suffix = substr($key, strlen($src)); // '' or '/child/...'
|
||||||
|
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
|
||||||
|
$new[$newKey] = $hex;
|
||||||
|
$moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moved) self::writeMap($new);
|
||||||
|
return ['changed'=> (bool)$moved, 'moved'=> $moved];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,45 @@ 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
|
||||||
|
{
|
||||||
|
// Normalize
|
||||||
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
|
||||||
|
// ACL gate: if you can’t read, report empty (no leaks)
|
||||||
|
if (!$user || !ACL::canRead($user, $perms, $folder)) {
|
||||||
|
return ['folders' => 0, 'files' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve paths under UPLOAD_DIR
|
||||||
|
$root = rtrim((string)UPLOAD_DIR, '/\\');
|
||||||
|
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
|
||||||
|
|
||||||
|
$realRoot = @realpath($root);
|
||||||
|
$realPath = @realpath($path);
|
||||||
|
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
|
||||||
|
return ['folders' => 0, 'files' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count quickly, skipping UI-internal dirs
|
||||||
|
$folders = 0; $files = 0;
|
||||||
|
try {
|
||||||
|
foreach (new DirectoryIterator($realPath) as $f) {
|
||||||
|
if ($f->isDot()) continue;
|
||||||
|
$name = $f->getFilename();
|
||||||
|
if ($name === 'trash' || $name === 'profile_pics') continue;
|
||||||
|
|
||||||
|
if ($f->isDir()) $folders++; else $files++;
|
||||||
|
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Stay quiet + safe
|
||||||
|
$folders = 0; $files = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['folders' => $folders, 'files' => $files];
|
||||||
|
}
|
||||||
|
|
||||||
/** Load the folder → owner map. */
|
/** Load the folder → owner map. */
|
||||||
public static function getFolderOwners(): array
|
public static function getFolderOwners(): array
|
||||||
{
|
{
|
||||||
|
|||||||