Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f |
174
.github/workflows/release-on-version.yml
vendored
@@ -6,160 +6,89 @@ 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 10 minutes
|
|
||||||
run: sleep 600
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: delay
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
# Only run on:
|
||||||
if: >
|
# - push (master + version.js path filter already enforces that)
|
||||||
|
# - manual dispatch
|
||||||
|
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
|
# Duplicate safety; also step "Skip if tag exists" will no-op if already released.
|
||||||
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
|
# Resolve to a commit sha (allow branches or shas)
|
||||||
VER="$VER_IN"
|
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||||
SRC="manual-version"
|
REF="$REF_IN"
|
||||||
|
else
|
||||||
|
# Accept SHAs too; we’ll let checkout validate
|
||||||
|
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
|
||||||
|
|
||||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||||
if [[ -z "$VER" ]]; then
|
echo "Using ref=$REF"
|
||||||
# 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
|
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||||
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"
|
# Parse APP_VERSION from public/js/version.js (expects vX.Y.Z)
|
||||||
echo "Parsed version (pre-resolved): $VER"
|
if [[ ! -f public/js/version.js ]]; then
|
||||||
exit 0
|
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||||
fi
|
exit 1
|
||||||
# Fallback to version.js
|
fi
|
||||||
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 version.js" >&2
|
echo "Could not parse APP_VERSION from public/js/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 +102,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 +110,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,7 +124,7 @@ 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)
|
- name: Verify placeholders removed
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -203,19 +132,12 @@ jobs:
|
|||||||
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" \
|
||||||
--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
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -223,7 +145,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 +190,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: |
|
||||||
|
|||||||
120
CHANGELOG.md
@@ -1,5 +1,125 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## Changes 11/8/2025 (v1.8.12)
|
||||||
|
|
||||||
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||||
|
|||||||
32
README.md
@@ -21,15 +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>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,21 +324,6 @@ https://your-host/webdav.php/
|
|||||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||||
- Click **Finish**.
|
- Click **Finish**.
|
||||||
|
|
||||||
> **Important:**
|
|
||||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
|
||||||
> If your server uses plain HTTP, you must adjust a registry setting:
|
|
||||||
>
|
|
||||||
> 1. Open **Registry Editor** (`regedit.exe`).
|
|
||||||
> 2. Navigate to:
|
|
||||||
>
|
|
||||||
> ```text
|
|
||||||
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
|
||||||
> 4. Set its value to `2`.
|
|
||||||
> 5. Restart the **WebClient** service or reboot.
|
|
||||||
|
|
||||||
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -404,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
|||||||
|
|
||||||
## Security posture
|
## Security posture
|
||||||
|
|
||||||
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||||
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||||
If you’re running ≤1.4.x, please upgrade.
|
If you’re running ≤1.4.x, please upgrade.
|
||||||
@@ -445,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)
|
||||||
@@ -478,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
Options -Indexes -Multiviews
|
Options -Indexes -Multiviews
|
||||||
DirectoryIndex index.html
|
DirectoryIndex index.html
|
||||||
|
|
||||||
|
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||||
|
AcceptPathInfo On
|
||||||
|
|
||||||
# ---------------- Security: dotfiles ----------------
|
# ---------------- Security: dotfiles ----------------
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||||
@@ -24,10 +27,14 @@ RewriteRule - - [L]
|
|||||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||||
|
|
||||||
# 2) Deny direct access to PHP outside /api/
|
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||||
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
|
# - allow /api/*.php (API endpoints)
|
||||||
RewriteCond %{REQUEST_URI} !^/api/
|
# - allow /api.php (ReDoc/spec page)
|
||||||
RewriteRule \.php$ - [F]
|
# - 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
|
# 3) Never redirect local/dev hosts
|
||||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||||
|
|||||||
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']);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
@@ -142,13 +187,13 @@ body {
|
|||||||
border-radius: 4px !important;
|
border-radius: 4px !important;
|
||||||
padding: 6px 10px !important;
|
padding: 6px 10px !important;
|
||||||
}
|
}
|
||||||
/* make the drop zone fill leftover space and right-align its own icons */
|
|
||||||
#headerDropArea.header-drop-zone{
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
min-width: 100px;
|
|
||||||
|
|
||||||
}
|
#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 {
|
.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);
|
||||||
@@ -801,14 +846,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;
|
||||||
}}
|
}}
|
||||||
@@ -821,10 +869,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;
|
||||||
@@ -834,7 +880,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,
|
||||||
@@ -1132,7 +1178,7 @@ 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;
|
||||||
@@ -1149,9 +1195,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 {
|
||||||
@@ -1532,7 +1579,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 {
|
||||||
@@ -1600,8 +1656,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,
|
||||||
@@ -1615,8 +1671,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;
|
||||||
@@ -1634,6 +1688,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;
|
||||||
@@ -1713,8 +1768,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 {
|
||||||
@@ -1947,4 +2003,172 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
#downloadProgressBarOuter { height: 10px; }
|
#downloadProgressBarOuter { height: 10px; }
|
||||||
|
|
||||||
|
/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */
|
||||||
|
|
||||||
|
/* Knobs (size, spacing, colors) */
|
||||||
|
#folderTreeContainer {
|
||||||
|
/* Colors (used in BOTH themes) */
|
||||||
|
--filr-folder-front: #f6b84e; /* front/lip */
|
||||||
|
--filr-folder-back: #ffd36e; /* back body */
|
||||||
|
--filr-folder-stroke:#a87312; /* outline */
|
||||||
|
--filr-paper-fill: #ffffff; /* paper */
|
||||||
|
--filr-paper-stroke: #b2c2db; /* paper edges/lines */
|
||||||
|
|
||||||
|
/* Size & spacing */
|
||||||
|
--row-h: 28px; /* row height */
|
||||||
|
--twisty: 24px; /* chevron hit-area size */
|
||||||
|
--twisty-gap: -5px; /* gap between chevron and row content */
|
||||||
|
--icon-size: 24px; /* 22–26 look good */
|
||||||
|
--icon-gap: 6px; /* space between icon and label */
|
||||||
|
--indent: 10px; /* subtree indent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */
|
||||||
|
.dark-mode #folderTreeContainer {
|
||||||
|
--filr-folder-front: #f6b84e;
|
||||||
|
--filr-folder-back: #ffd36e;
|
||||||
|
--filr-folder-stroke:#a87312;
|
||||||
|
--filr-paper-fill: #ffffff;
|
||||||
|
--filr-paper-stroke: #d0def7; /* brighter so it pops on dark */
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||||
|
|
||||||
|
/* visible “row” for each node */
|
||||||
|
#folderTreeContainer .folder-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--row-h);
|
||||||
|
padding-left: calc(var(--twisty) + var(--twisty-gap));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* children indent */
|
||||||
|
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||||
|
|
||||||
|
/* ---------- Chevron toggle (twisty) ---------- */
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-row > button.folder-toggle {
|
||||||
|
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: "▸"; /* closed */
|
||||||
|
font-size: calc(var(--twisty) * 0.8);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
|
||||||
|
> .folder-row > button.folder-toggle::before { content: "▾"; }
|
||||||
|
|
||||||
|
/* root row (it's a <div>) */
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* spacer for leaves so labels align with parents that have a button */
|
||||||
|
#folderTreeContainer .folder-row > .folder-spacer {
|
||||||
|
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
width: var(--twisty); height: var(--twisty); display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--row-h);
|
||||||
|
line-height: 1.2; /* avoids baseline weirdness */
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: var(--icon-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transform: translateY(0.5px); /* tiny optical nudge for text */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Icon box (size & alignment) ---------- */
|
||||||
|
|
||||||
|
#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;
|
||||||
|
transform: translateY(0.5px); /* tiny optical nudge for SVG */
|
||||||
|
}
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Crisp colors & strokes for the SVG parts ---------- */
|
||||||
|
|
||||||
|
|
||||||
|
#folderTreeContainer .folder-icon .paper {
|
||||||
|
fill: var(--filr-paper-fill);
|
||||||
|
stroke: var(--filr-paper-stroke);
|
||||||
|
stroke-width: 1.5; /* thick so it reads at 24px */
|
||||||
|
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: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* subtle highlight along lip to add depth */
|
||||||
|
#folderTreeContainer .folder-icon .lip-highlight {
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-opacity: .35;
|
||||||
|
stroke-width: 0.9;
|
||||||
|
fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hover / Selected ---------- */
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* variables will be set inline per .folder-option when user colors a folder */
|
||||||
|
#folderTreeContainer .folder-icon .folder-front,
|
||||||
|
#folderTreeContainer .folder-icon .folder-back {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: var(--filr-folder-stroke);
|
||||||
|
stroke-width: 1.1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||||
|
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||||
|
|||||||
@@ -252,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>
|
||||||
@@ -352,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 -->
|
||||||
@@ -491,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>
|
||||||
@@ -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('fileListContainer');
|
||||||
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 }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ function readLayout() {
|
|||||||
function writeLayout(layout) {
|
function writeLayout(layout) {
|
||||||
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {}));
|
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLayoutFor(cardId, zoneId) {
|
function setLayoutFor(cardId, zoneId) {
|
||||||
const layout = readLayout();
|
const layout = readLayout();
|
||||||
layout[cardId] = zoneId;
|
layout[cardId] = zoneId;
|
||||||
@@ -93,6 +92,7 @@ function removeHeaderIconForCard(card) {
|
|||||||
function insertCardInHeader(card) {
|
function insertCardInHeader(card) {
|
||||||
const host = getHeaderDropArea();
|
const host = getHeaderDropArea();
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
// Ensure hidden container exists to park real cards while icon-visible.
|
// Ensure hidden container exists to park real cards while icon-visible.
|
||||||
let hidden = $('hiddenCardsContainer');
|
let hidden = $('hiddenCardsContainer');
|
||||||
if (!hidden) {
|
if (!hidden) {
|
||||||
@@ -110,10 +110,10 @@ function insertCardInHeader(card) {
|
|||||||
iconButton.style.border = 'none';
|
iconButton.style.border = 'none';
|
||||||
iconButton.style.background = 'none';
|
iconButton.style.background = 'none';
|
||||||
iconButton.style.cursor = 'pointer';
|
iconButton.style.cursor = 'pointer';
|
||||||
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${card.id === 'uploadCard' ? 'cloud_upload'
|
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${
|
||||||
: card.id === 'folderManagementCard' ? 'folder'
|
card.id === 'uploadCard' ? 'cloud_upload' :
|
||||||
: 'insert_drive_file'
|
card.id === 'folderManagementCard' ? 'folder' : 'insert_drive_file'
|
||||||
}</i>`;
|
}</i>`;
|
||||||
|
|
||||||
iconButton.cardElement = card;
|
iconButton.cardElement = card;
|
||||||
card.headerIconButton = iconButton;
|
card.headerIconButton = iconButton;
|
||||||
@@ -150,8 +150,8 @@ function insertCardInHeader(card) {
|
|||||||
function showModal() {
|
function showModal() {
|
||||||
ensureModal();
|
ensureModal();
|
||||||
if (!modal.contains(card)) {
|
if (!modal.contains(card)) {
|
||||||
let hidden = $('hiddenCardsContainer');
|
const hiddenNow = $('hiddenCardsContainer');
|
||||||
if (hidden && hidden.contains(card)) hidden.removeChild(card);
|
if (hiddenNow && hiddenNow.contains(card)) hiddenNow.removeChild(card);
|
||||||
card.style.width = '';
|
card.style.width = '';
|
||||||
card.style.minWidth = '';
|
card.style.minWidth = '';
|
||||||
modal.appendChild(card);
|
modal.appendChild(card);
|
||||||
@@ -163,8 +163,8 @@ function insertCardInHeader(card) {
|
|||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
modal.style.visibility = 'hidden';
|
modal.style.visibility = 'hidden';
|
||||||
modal.style.opacity = '0';
|
modal.style.opacity = '0';
|
||||||
const hidden = $('hiddenCardsContainer');
|
const hiddenNow = $('hiddenCardsContainer');
|
||||||
if (hidden && modal.contains(card)) hidden.appendChild(card);
|
if (hiddenNow && modal.contains(card)) hiddenNow.appendChild(card);
|
||||||
}
|
}
|
||||||
function maybeHide() {
|
function maybeHide() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -181,6 +181,8 @@ function insertCardInHeader(card) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
host.appendChild(iconButton);
|
host.appendChild(iconButton);
|
||||||
|
// make sure the dock is visible when icons exist
|
||||||
|
showHeaderDockPersistent();
|
||||||
saveHeaderOrder();
|
saveHeaderOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +229,10 @@ function placeCardInZone(card, zoneId, { animate = true } = {}) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTopZoneLayout();
|
||||||
|
updateSidebarVisibility();
|
||||||
|
updateZonesToggleUI(); // live update when zones change
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentZoneForCard(card) {
|
function currentZoneForCard(card) {
|
||||||
@@ -234,7 +240,6 @@ function currentZoneForCard(card) {
|
|||||||
const pid = card.parentNode.id || '';
|
const pid = card.parentNode.id || '';
|
||||||
if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER;
|
if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER;
|
||||||
if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid;
|
if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid;
|
||||||
// If card is temporarily in modal (header), treat as header
|
|
||||||
if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER;
|
if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER;
|
||||||
return pid || null;
|
return pid || null;
|
||||||
}
|
}
|
||||||
@@ -248,44 +253,6 @@ function saveCurrentLayout() {
|
|||||||
writeLayout(layout);
|
writeLayout(layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyUserLayoutOrDefault() {
|
|
||||||
const layout = readLayout();
|
|
||||||
const hasAny = Object.keys(layout).length > 0;
|
|
||||||
|
|
||||||
// If we have saved user layout, honor it
|
|
||||||
if (hasAny) {
|
|
||||||
getCards().forEach(card => {
|
|
||||||
const targetZone = layout[card.id];
|
|
||||||
if (!targetZone) return;
|
|
||||||
// On small screens: if saved zone is the sidebar, temporarily place in top cols
|
|
||||||
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
|
|
||||||
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
|
||||||
placeCardInZone(card, target, { animate: false });
|
|
||||||
} else {
|
|
||||||
placeCardInZone(card, targetZone, { animate: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
updateTopZoneLayout();
|
|
||||||
updateSidebarVisibility();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No saved layout yet: apply defaults
|
|
||||||
if (!isSmallScreen()) {
|
|
||||||
// Wide: default both to sidebar (if not already)
|
|
||||||
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
|
|
||||||
} else {
|
|
||||||
// Small: deterministic mapping
|
|
||||||
getCards().forEach(c => {
|
|
||||||
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
|
||||||
placeCardInZone(c, zone, { animate: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateTopZoneLayout();
|
|
||||||
updateSidebarVisibility();
|
|
||||||
saveCurrentLayout(); // initialize baseline so future moves persist
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- responsive stash --------------------
|
// -------------------- responsive stash --------------------
|
||||||
function stashSidebarCardsBeforeSmall() {
|
function stashSidebarCardsBeforeSmall() {
|
||||||
const sb = getSidebar();
|
const sb = getSidebar();
|
||||||
@@ -339,21 +306,62 @@ function enforceResponsiveZones() {
|
|||||||
__wasSmall = nowSmall;
|
__wasSmall = nowSmall;
|
||||||
updateTopZoneLayout();
|
updateTopZoneLayout();
|
||||||
updateSidebarVisibility();
|
updateSidebarVisibility();
|
||||||
|
updateZonesToggleUI(); // keep icon in sync when responsive flips
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- header dock visibility helpers --------------------
|
||||||
|
function showHeaderDockPersistent() {
|
||||||
|
const h = getHeaderDropArea();
|
||||||
|
if (h) {
|
||||||
|
h.style.display = 'inline-flex';
|
||||||
|
h.classList.add('dock-visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideHeaderDockPersistent() {
|
||||||
|
const h = getHeaderDropArea();
|
||||||
|
if (h) {
|
||||||
|
h.classList.remove('dock-visible');
|
||||||
|
if (h.children.length === 0) h.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- zones toggle (collapse to header) --------------------
|
// -------------------- zones toggle (collapse to header) --------------------
|
||||||
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
||||||
|
|
||||||
|
function applyCollapsedBodyClass() {
|
||||||
|
// helps grid/containers expand the file list area when sidebar is hidden
|
||||||
|
document.body.classList.toggle('sidebar-hidden', isZonesCollapsed());
|
||||||
|
const main = document.querySelector('.main-wrapper') || document.querySelector('#main') || document.querySelector('main');
|
||||||
|
if (main) {
|
||||||
|
main.style.contain = 'size';
|
||||||
|
void main.offsetHeight;
|
||||||
|
setTimeout(() => { main.style.removeProperty('contain'); }, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setZonesCollapsed(collapsed) {
|
function setZonesCollapsed(collapsed) {
|
||||||
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
|
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
// Move ALL cards to header icons (transient). Do not overwrite saved layout.
|
// Move ALL cards to header icons (transient) regardless of where they were.
|
||||||
getCards().forEach(insertCardInHeader);
|
getCards().forEach(insertCardInHeader);
|
||||||
|
showHeaderDockPersistent();
|
||||||
|
const sb = getSidebar();
|
||||||
|
if (sb) sb.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
// Restore the saved user layout.
|
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
|
||||||
applyUserLayoutOrDefault();
|
applyUserLayoutOrDefault();
|
||||||
|
loadHeaderOrder();
|
||||||
|
hideHeaderDockPersistent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSidebarVisibility();
|
||||||
|
updateTopZoneLayout();
|
||||||
ensureZonesToggle();
|
ensureZonesToggle();
|
||||||
updateZonesToggleUI();
|
updateZonesToggleUI();
|
||||||
|
applyCollapsedBodyClass();
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaderHost() {
|
function getHeaderHost() {
|
||||||
@@ -379,7 +387,6 @@ function ensureZonesToggle() {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: `${TOGGLE_TOP_PX}px`,
|
top: `${TOGGLE_TOP_PX}px`,
|
||||||
left: `${TOGGLE_LEFT_PX}px`,
|
left: `${TOGGLE_LEFT_PX}px`,
|
||||||
zIndex: '10010',
|
|
||||||
width: '38px',
|
width: '38px',
|
||||||
height: '38px',
|
height: '38px',
|
||||||
borderRadius: '19px',
|
borderRadius: '19px',
|
||||||
@@ -424,7 +431,7 @@ function updateZonesToggleUI() {
|
|||||||
iconEl.style.transition = 'transform 0.2s ease';
|
iconEl.style.transition = 'transform 0.2s ease';
|
||||||
iconEl.style.display = 'inline-flex';
|
iconEl.style.display = 'inline-flex';
|
||||||
iconEl.style.alignItems = 'center';
|
iconEl.style.alignItems = 'center';
|
||||||
// fun rotate if both cards are in top zone
|
// rotate if both cards are in top zone (only when not collapsed)
|
||||||
const tz = getTopZone();
|
const tz = getTopZone();
|
||||||
const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard');
|
const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard');
|
||||||
iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)';
|
iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)';
|
||||||
@@ -486,7 +493,31 @@ function updateTopZoneLayout() {
|
|||||||
if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none';
|
if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// drag visual helpers
|
// --- sidebar placeholder while dragging (only when empty) ---
|
||||||
|
function ensureSidebarPlaceholder() {
|
||||||
|
const sb = getSidebar();
|
||||||
|
if (!sb) return;
|
||||||
|
if (hasSidebarCards()) return; // only when empty
|
||||||
|
let ph = sb.querySelector('.sb-dnd-placeholder');
|
||||||
|
if (!ph) {
|
||||||
|
ph = document.createElement('div');
|
||||||
|
ph.className = 'sb-dnd-placeholder';
|
||||||
|
Object.assign(ph.style, {
|
||||||
|
height: '340px',
|
||||||
|
width: '100%',
|
||||||
|
visibility: 'hidden'
|
||||||
|
});
|
||||||
|
sb.appendChild(ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeSidebarPlaceholder() {
|
||||||
|
const sb = getSidebar();
|
||||||
|
if (!sb) return;
|
||||||
|
const ph = sb.querySelector('.sb-dnd-placeholder');
|
||||||
|
if (ph) ph.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- DnD core --------------------
|
||||||
function addTopZoneHighlight() {
|
function addTopZoneHighlight() {
|
||||||
const top = getTopZone();
|
const top = getTopZone();
|
||||||
if (!top) return;
|
if (!top) return;
|
||||||
@@ -525,6 +556,7 @@ function cleanupTopZoneAfterDrop() {
|
|||||||
if (ph) ph.remove();
|
if (ph) ph.remove();
|
||||||
top.classList.remove('highlight');
|
top.classList.remove('highlight');
|
||||||
top.style.minHeight = '';
|
top.style.minHeight = '';
|
||||||
|
// ✅ fixed selector string here
|
||||||
const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0;
|
const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0;
|
||||||
top.style.display = hasAny ? '' : 'none';
|
top.style.display = hasAny ? '' : 'none';
|
||||||
}
|
}
|
||||||
@@ -539,11 +571,10 @@ function hideHeaderDropZone() {
|
|||||||
const h = getHeaderDropArea();
|
const h = getHeaderDropArea();
|
||||||
if (h) {
|
if (h) {
|
||||||
h.classList.remove('drag-active');
|
h.classList.remove('drag-active');
|
||||||
if (h.children.length === 0) h.style.display = 'none';
|
if (h.children.length === 0 && !isZonesCollapsed()) h.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- DnD core --------------------
|
|
||||||
function makeCardDraggable(card) {
|
function makeCardDraggable(card) {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const header = card.querySelector('.card-header');
|
const header = card.querySelector('.card-header');
|
||||||
@@ -573,11 +604,9 @@ function makeCardDraggable(card) {
|
|||||||
|
|
||||||
const sb = getSidebar();
|
const sb = getSidebar();
|
||||||
if (sb) {
|
if (sb) {
|
||||||
sb.classList.add('active');
|
sb.classList.add('active', 'highlight');
|
||||||
sb.classList.add('highlight');
|
|
||||||
if (!isZonesCollapsed()) sb.style.display = 'block';
|
if (!isZonesCollapsed()) sb.style.display = 'block';
|
||||||
sb.style.removeProperty('height');
|
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
|
||||||
sb.style.minWidth = '280px';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showHeaderDropZone();
|
showHeaderDropZone();
|
||||||
@@ -597,9 +626,8 @@ function makeCardDraggable(card) {
|
|||||||
top: initialTop + 'px',
|
top: initialTop + 'px',
|
||||||
width: rect.width + 'px',
|
width: rect.width + 'px',
|
||||||
height: rect.height + 'px',
|
height: rect.height + 'px',
|
||||||
minWidth: rect.width + 'px',
|
zIndex: '10000',
|
||||||
flexShrink: '0',
|
pointerEvents: 'none'
|
||||||
zIndex: '10000'
|
|
||||||
});
|
});
|
||||||
}, 450);
|
}, 450);
|
||||||
});
|
});
|
||||||
@@ -623,8 +651,7 @@ function makeCardDraggable(card) {
|
|||||||
const sb = getSidebar();
|
const sb = getSidebar();
|
||||||
if (sb) {
|
if (sb) {
|
||||||
sb.classList.remove('highlight');
|
sb.classList.remove('highlight');
|
||||||
sb.style.height = '';
|
removeSidebarPlaceholder();
|
||||||
sb.style.minWidth = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dropped = null;
|
let dropped = null;
|
||||||
@@ -663,22 +690,20 @@ function makeCardDraggable(card) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not dropped anywhere, return to original container
|
|
||||||
if (!dropped) {
|
if (!dropped) {
|
||||||
|
// return to original container
|
||||||
const orig = $(card.dataset.originalContainerId);
|
const orig = $(card.dataset.originalContainerId);
|
||||||
if (orig) {
|
if (orig) {
|
||||||
orig.appendChild(card);
|
orig.appendChild(card);
|
||||||
card.style.removeProperty('width');
|
card.style.removeProperty('width');
|
||||||
animateVerticalSlide(card);
|
animateVerticalSlide(card);
|
||||||
// keep previous zone in layout (no change)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Persist user layout on manual move (including header)
|
|
||||||
setLayoutFor(card.id, dropped);
|
setLayoutFor(card.id, dropped);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear inline drag styles
|
// Clear inline drag styles
|
||||||
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width']
|
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width', 'pointer-events']
|
||||||
.forEach(prop => card.style.removeProperty(prop));
|
.forEach(prop => card.style.removeProperty(prop));
|
||||||
|
|
||||||
removeTopZoneHighlight();
|
removeTopZoneHighlight();
|
||||||
@@ -686,15 +711,52 @@ function makeCardDraggable(card) {
|
|||||||
cleanupTopZoneAfterDrop();
|
cleanupTopZoneAfterDrop();
|
||||||
updateTopZoneLayout();
|
updateTopZoneLayout();
|
||||||
updateSidebarVisibility();
|
updateSidebarVisibility();
|
||||||
|
updateZonesToggleUI();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- defaults + layout --------------------
|
||||||
|
function applyUserLayoutOrDefault() {
|
||||||
|
const layout = readLayout();
|
||||||
|
const hasAny = Object.keys(layout).length > 0;
|
||||||
|
|
||||||
|
if (hasAny) {
|
||||||
|
getCards().forEach(card => {
|
||||||
|
const targetZone = layout[card.id];
|
||||||
|
if (!targetZone) return;
|
||||||
|
// On small screens: if saved zone is the sidebar, temporarily place in top cols
|
||||||
|
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
|
||||||
|
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||||
|
placeCardInZone(card, target, { animate: false });
|
||||||
|
} else {
|
||||||
|
placeCardInZone(card, targetZone, { animate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateTopZoneLayout();
|
||||||
|
updateSidebarVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No saved layout yet: apply defaults
|
||||||
|
if (!isSmallScreen()) {
|
||||||
|
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
|
||||||
|
} else {
|
||||||
|
getCards().forEach(c => {
|
||||||
|
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||||
|
placeCardInZone(c, zone, { animate: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateTopZoneLayout();
|
||||||
|
updateSidebarVisibility();
|
||||||
|
saveCurrentLayout(); // initialize baseline so future moves persist
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- public API --------------------
|
// -------------------- public API --------------------
|
||||||
export function loadSidebarOrder() {
|
export function loadSidebarOrder() {
|
||||||
// Backward compat: act as "apply layout"
|
|
||||||
applyUserLayoutOrDefault();
|
applyUserLayoutOrDefault();
|
||||||
ensureZonesToggle();
|
ensureZonesToggle();
|
||||||
updateZonesToggleUI();
|
updateZonesToggleUI();
|
||||||
|
applyCollapsedBodyClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHeaderOrder() {
|
export function loadHeaderOrder() {
|
||||||
@@ -704,9 +766,9 @@ export function loadHeaderOrder() {
|
|||||||
|
|
||||||
const layout = readLayout();
|
const layout = readLayout();
|
||||||
|
|
||||||
// If collapsed: all cards appear as header icons
|
|
||||||
if (isZonesCollapsed()) {
|
if (isZonesCollapsed()) {
|
||||||
getCards().forEach(insertCardInHeader);
|
getCards().forEach(insertCardInHeader);
|
||||||
|
showHeaderDockPersistent();
|
||||||
saveHeaderOrder();
|
saveHeaderOrder();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -715,6 +777,7 @@ export function loadHeaderOrder() {
|
|||||||
getCards().forEach(card => {
|
getCards().forEach(card => {
|
||||||
if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card);
|
if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card);
|
||||||
});
|
});
|
||||||
|
if (header.children.length === 0) header.style.display = 'none';
|
||||||
saveHeaderOrder();
|
saveHeaderOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,6 +790,7 @@ export function initDragAndDrop() {
|
|||||||
// 2) Paint controls/UI
|
// 2) Paint controls/UI
|
||||||
ensureZonesToggle();
|
ensureZonesToggle();
|
||||||
updateZonesToggleUI();
|
updateZonesToggleUI();
|
||||||
|
applyCollapsedBodyClass();
|
||||||
|
|
||||||
// 3) Make cards draggable
|
// 3) Make cards draggable
|
||||||
getCards().forEach(makeCardDraggable);
|
getCards().forEach(makeCardDraggable);
|
||||||
@@ -735,9 +799,7 @@ export function initDragAndDrop() {
|
|||||||
let raf = null;
|
let raf = null;
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
if (raf) cancelAnimationFrame(raf);
|
if (raf) cancelAnimationFrame(raf);
|
||||||
raf = requestAnimationFrame(() => {
|
raf = requestAnimationFrame(() => enforceResponsiveZones());
|
||||||
enforceResponsiveZones();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
enforceResponsiveZones();
|
enforceResponsiveZones();
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -300,6 +348,7 @@ 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'));
|
||||||
@@ -633,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);
|
||||||
}
|
}
|
||||||
@@ -685,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"));
|
||||||
}
|
}
|
||||||
@@ -822,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const menu = document.getElementById('createMenu');
|
const menu = document.getElementById('createMenu');
|
||||||
const fileOpt = document.getElementById('createFileOption');
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
const folderOpt = document.getElementById('createFolderOption');
|
const folderOpt = document.getElementById('createFolderOption');
|
||||||
|
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||||
|
|
||||||
// Toggle dropdown on click
|
// Toggle dropdown on click
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -846,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;
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.12';
|
window.APP_VERSION = 'v1.9.2';
|
||||||
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 788 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 541 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."
|
||||||
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');
|
||||||
|
|||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||