Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 |
217
.github/workflows/release-on-version.yml
vendored
217
.github/workflows/release-on-version.yml
vendored
@@ -2,164 +2,83 @@
|
||||
name: Release on version.js update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- public/js/version.js
|
||||
workflow_run:
|
||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
||||
description: "Ref (branch/sha) to build from (default: master)"
|
||||
required: false
|
||||
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
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
delay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delay 10 minutes
|
||||
run: sleep 600
|
||||
|
||||
release:
|
||||
needs: delay
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
||||
if: >
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
# Use run_id for a stable, unique key
|
||||
concurrency:
|
||||
group: release-${{ github.run_id }}
|
||||
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout (fetch all)
|
||||
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
|
||||
- name: Resolve source ref
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
REF=""
|
||||
VER=""
|
||||
SRC=""
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# manual run
|
||||
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
|
||||
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
else
|
||||
REF="$(git rev-parse origin/master)"
|
||||
REF_IN="master"
|
||||
fi
|
||||
if [[ -n "$VER_IN" ]]; then
|
||||
VER="$VER_IN"
|
||||
SRC="manual-version"
|
||||
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||
REF="$REF_IN"
|
||||
else
|
||||
REF="$REF_IN"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
REF="${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Using ref=$REF"
|
||||
|
||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
||||
if [[ -z "$VER" ]]; then
|
||||
# Search recent history reachable from REF
|
||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
||||
if [[ -n "$BOT_SHA" ]]; then
|
||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
||||
if [[ -n "$BOT_VER" ]]; then
|
||||
VER="$BOT_VER"
|
||||
REF="$BOT_SHA" # build/tag from the bump commit
|
||||
SRC="bot-commit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output
|
||||
REF_SHA="$(git rev-parse "$REF")"
|
||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
||||
|
||||
- name: Checkout chosen ref
|
||||
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
|
||||
- name: Assert ref is on master
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REF="${{ steps.pickref.outputs.ref }}"
|
||||
git fetch origin master --quiet
|
||||
if ! git merge-base --is-ancestor "$REF" origin/master; then
|
||||
echo "Ref $REF is not on master; refusing to release."
|
||||
exit 78
|
||||
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
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Prefer pre-resolved version (manual input or bot commit)
|
||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
||||
VER="${{ steps.pickref.outputs.preversion }}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (pre-resolved): $VER"
|
||||
exit 0
|
||||
fi
|
||||
# Fallback to version.js
|
||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||
if [[ -z "$VER" ]]; then
|
||||
echo "Could not parse APP_VERSION from version.js" >&2
|
||||
exit 1
|
||||
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||
VER="${{ github.event.inputs.version }}"
|
||||
else
|
||||
if [[ ! -f public/js/version.js ]]; then
|
||||
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||
exit 1
|
||||
fi
|
||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||
if [[ -z "$VER" ]]; then
|
||||
echo "Could not parse APP_VERSION from public/js/version.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (file): $VER"
|
||||
echo "Detected version: $VER"
|
||||
|
||||
- name: Skip if tag already exists
|
||||
id: tagcheck
|
||||
@@ -173,7 +92,7 @@ jobs:
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prep stamper script
|
||||
- name: Prepare stamp script
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -181,7 +100,7 @@ jobs:
|
||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||
chmod +x scripts/stamp-assets.sh
|
||||
|
||||
- name: Build zip artifact (stamped)
|
||||
- name: Build stamped staging tree
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -195,27 +114,67 @@ jobs:
|
||||
./ staging/
|
||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
||||
|
||||
- name: Verify placeholders are gone (staging)
|
||||
# --- PHP + Composer for vendor/ (production) ---
|
||||
- name: Setup PHP
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: php
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
|
||||
coverage: none
|
||||
ini-values: memory_limit=-1
|
||||
|
||||
- name: Cache Composer downloads
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.composer/cache
|
||||
~/.cache/composer
|
||||
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
|
||||
|
||||
- name: Install PHP dependencies into staging
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
env:
|
||||
COMPOSER_MEMORY_LIMIT: -1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pushd staging >/dev/null
|
||||
if [[ -f composer.json ]]; then
|
||||
composer install \
|
||||
--no-dev \
|
||||
--prefer-dist \
|
||||
--no-interaction \
|
||||
--no-progress \
|
||||
--optimize-autoloader \
|
||||
--classmap-authoritative
|
||||
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
|
||||
else
|
||||
echo "No composer.json in staging; skipping vendor install."
|
||||
fi
|
||||
popd >/dev/null
|
||||
# --- end Composer ---
|
||||
|
||||
- name: Verify placeholders removed (skip vendor/)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT="$(pwd)/staging"
|
||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--exclude-dir=vendor --exclude-dir=vendor-bin \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||
echo "---- DEBUG (show 10 hits with context) ----"
|
||||
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
|
||||
| head -n 10 | while IFS=: read -r file line _; do
|
||||
echo ">>> $file:$line"
|
||||
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
|
||||
echo "----------------------------------------"
|
||||
done
|
||||
echo "Unreplaced placeholders found in staging." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: No unreplaced placeholders in staging."
|
||||
echo "OK: No unreplaced placeholders."
|
||||
|
||||
- name: Zip stamped staging
|
||||
- name: Zip artifact (includes vendor/)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -223,7 +182,7 @@ jobs:
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||
|
||||
- name: Compute SHA-256 checksum
|
||||
- name: Compute SHA-256
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: sum
|
||||
shell: bash
|
||||
@@ -268,9 +227,9 @@ jobs:
|
||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||
fi
|
||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||
echo "Previous tag or baseline: $PREV"
|
||||
echo "Previous tag/baseline: $PREV"
|
||||
|
||||
- name: Build release body (snippet + full changelog + checksum)
|
||||
- name: Build release body
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
146
CHANGELOG.md
146
CHANGELOG.md
@@ -1,5 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/13/2025 (v1.9.5)
|
||||
|
||||
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
|
||||
|
||||
- Replace innerHTML-based row construction in folderManager.js with safe DOM APIs
|
||||
(createElement, textContent, dataset). All user-derived strings now use
|
||||
textContent; only locally-generated SVG remains via innerHTML.
|
||||
- Add isSafeFolderPath() client-side guard; fail closed on suspicious paths
|
||||
before rendering clickable nodes.
|
||||
- “Load more” button rebuilt with proper a11y:
|
||||
- aria-label, optional aria-controls to the UL
|
||||
- aria-busy + disabled during fetch; restore state only if the node is still
|
||||
present (Node.isConnected).
|
||||
- Keep lazy tree + cursor pagination behavior intact; chevrons/icons continue to
|
||||
hydrate from server hints (hasSubfolders/nonEmpty) once available.
|
||||
- Addresses CodeQL XSS findings by removing unsafe HTML interpolation and
|
||||
avoiding HTML interpretation of extracted text.
|
||||
|
||||
No breaking changes; security + UX polish on top of v1.9.4.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/13/2025 (v1.9.4)
|
||||
|
||||
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
|
||||
|
||||
**Big focus on folder management performance & UX for large libraries.**
|
||||
|
||||
feat(folder-tree):
|
||||
|
||||
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
|
||||
- BFS-based initial selection: if user can’t view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
|
||||
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
|
||||
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
|
||||
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
|
||||
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
|
||||
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
|
||||
- Root DnD honored only when viewable; structural locks disable dragging.
|
||||
|
||||
perf(core):
|
||||
|
||||
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
|
||||
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
|
||||
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
|
||||
|
||||
api/model:
|
||||
|
||||
- `FolderModel::listChildren(...)` now returns items shaped like:
|
||||
`{ name, locked, hasSubfolders, nonEmpty? }`
|
||||
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
|
||||
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
|
||||
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
|
||||
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
|
||||
|
||||
ui/ux:
|
||||
|
||||
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
|
||||
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
|
||||
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
|
||||
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
|
||||
|
||||
security:
|
||||
|
||||
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
|
||||
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
|
||||
|
||||
fixes:
|
||||
|
||||
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
|
||||
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
|
||||
|
||||
chore:
|
||||
|
||||
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
|
||||
- Minor code cleanups and comments across controller/model and JS tree logic.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/11/2025 (v1.9.3)
|
||||
|
||||
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
|
||||
|
||||
- UI / Icons
|
||||
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
|
||||
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
|
||||
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
|
||||
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
|
||||
|
||||
- Folder color & non-empty detection
|
||||
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
|
||||
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
|
||||
|
||||
- FileList strip
|
||||
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
|
||||
|
||||
- Exports & helpers
|
||||
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
|
||||
|
||||
- AppCore
|
||||
- Update file upload DnD relay hook to `#fileList` (id rename).
|
||||
|
||||
- CSS tweaks
|
||||
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
|
||||
|
||||
- CI/CD (release)
|
||||
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
|
||||
|
||||
- Changelog highlights
|
||||
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
|
||||
- Real-time folder color propagation between views.
|
||||
- Folder strip switched to SVG tiles with better layout + context actions.
|
||||
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/10/2025 (v1.9.2)
|
||||
|
||||
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||
|
||||
- New “Upload file(s)” action in Create menu:
|
||||
- Adds `<li id="uploadOption">` to the dropdown.
|
||||
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||
|
||||
- Drag & Drop from file list → Upload:
|
||||
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||
|
||||
- Synthetic drop fallback:
|
||||
- Introduces window.__pendingDropData (cleared after use).
|
||||
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||
|
||||
- Implementation details:
|
||||
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||
|
||||
- UX/Safety:
|
||||
- Defensive checks if modal/card isn’t present.
|
||||
- No backend/API changes; CSRF/auth unchanged.
|
||||
|
||||
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/9/2025 (v1.9.1)
|
||||
|
||||
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||
|
||||
23
README.md
23
README.md
@@ -21,9 +21,7 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
|
||||
|
||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
||||
|
||||
New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
**10/25/2025 Video demo:**
|
||||
|
||||
@@ -326,21 +324,6 @@ https://your-host/webdav.php/
|
||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||
- Click **Finish**.
|
||||
|
||||
> **Important:**
|
||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||
> If your server uses plain HTTP, you must adjust a registry setting:
|
||||
>
|
||||
> 1. Open **Registry Editor** (`regedit.exe`).
|
||||
> 2. Navigate to:
|
||||
>
|
||||
> ```text
|
||||
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
|
||||
> ```
|
||||
>
|
||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||
> 4. Set its value to `2`.
|
||||
> 5. Restart the **WebClient** service or reboot.
|
||||
|
||||
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||
|
||||
---
|
||||
@@ -404,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
## Security posture
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||
If you’re running ≤1.4.x, please upgrade.
|
||||
@@ -471,7 +456,7 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,245 +1,18 @@
|
||||
<?php
|
||||
// public/api/folder/capabilities.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/folder/capabilities.php",
|
||||
* summary="Get effective capabilities for the current user in a folder",
|
||||
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
|
||||
* operationId="getFolderCapabilities",
|
||||
* tags={"Folders"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="folder",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
|
||||
* @OA\Schema(type="string"),
|
||||
* example="projects/acme"
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Capabilities computed successfully.",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
|
||||
* @OA\Property(property="user", type="string", example="alice"),
|
||||
* @OA\Property(property="folder", type="string", example="projects/acme"),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=false),
|
||||
* @OA\Property(
|
||||
* property="flags",
|
||||
* type="object",
|
||||
* required={"folderOnly","readOnly","disableUpload"},
|
||||
* @OA\Property(property="folderOnly", type="boolean", example=false),
|
||||
* @OA\Property(property="readOnly", type="boolean", example=false),
|
||||
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
||||
* ),
|
||||
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
|
||||
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
|
||||
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
|
||||
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
|
||||
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
|
||||
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
|
||||
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
|
||||
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid folder name."),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
@session_write_close();
|
||||
|
||||
// --- auth ---
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if ($username === '') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||
|
||||
// --- helpers ---
|
||||
function loadPermsFor(string $u): array {
|
||||
try {
|
||||
if (function_exists('loadUserPermissions')) {
|
||||
$p = loadUserPermissions($u);
|
||||
return is_array($p) ? $p : [];
|
||||
}
|
||||
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
||||
$all = userModel::getUserPermissions();
|
||||
if (is_array($all)) {
|
||||
if (isset($all[$u])) return (array)$all[$u];
|
||||
$lk = strtolower($u);
|
||||
if (isset($all[$lk])) return (array)$all[$lk];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
// direct owner
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
// ancestor owner
|
||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||
$pos = strrpos($f, '/');
|
||||
if ($pos === false) break;
|
||||
$f = substr($f, 0, $pos);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* folder-only scope:
|
||||
* - Admins: always in scope
|
||||
* - Non folder-only accounts: always in scope
|
||||
* - Folder-only accounts: in scope iff:
|
||||
* - folder == username OR subpath of username, OR
|
||||
* - user is owner of this folder (or any ancestor)
|
||||
*/
|
||||
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
//if (!$folderOnly) return true;
|
||||
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if ($f === 'root' || $f === '') {
|
||||
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||
|
||||
// Treat ownership as in-scope
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
|
||||
// validate folder path
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
// --- user + flags ---
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = ACL::isAdmin($perms);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
|
||||
// --- ACL base abilities ---
|
||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
|
||||
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
||||
|
||||
// granular base
|
||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||
|
||||
// --- Apply scope + flags to effective UI actions ---
|
||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||
// Destination can receive items if user can create/write (or manage) here
|
||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||
$canMoveIn = $canReceive;
|
||||
$canMoveAlias = $canMoveIn;
|
||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||
|
||||
// Sharing respects scope; optionally also gate on readOnly
|
||||
$canShare = $canShareBase && $inScope; // legacy umbrella
|
||||
$canShareFileEff = $gShareFile && $inScope;
|
||||
$canShareFoldEff = $gShareFolder && $inScope;
|
||||
|
||||
// never allow destructive ops on root
|
||||
$isRoot = ($folder === 'root');
|
||||
if ($isRoot) {
|
||||
$canRename = false;
|
||||
$canDelete = false;
|
||||
$canShareFoldEff = false;
|
||||
$canMoveFolder = false;
|
||||
}
|
||||
|
||||
if (!$isRoot) {
|
||||
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||
&& !$readOnly;
|
||||
}
|
||||
|
||||
$owner = null;
|
||||
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
|
||||
|
||||
echo json_encode([
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'readOnly' => $readOnly,
|
||||
],
|
||||
'owner' => $owner,
|
||||
|
||||
// viewing
|
||||
'canView' => $canView,
|
||||
'canViewOwn' => $canViewOwn,
|
||||
|
||||
// write-ish
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreate,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canMove' => $canMoveAlias,
|
||||
'canMoveFolder'=> $canMoveFolder,
|
||||
'canEdit' => $canEdit,
|
||||
'canCopy' => $canCopy,
|
||||
'canExtract' => $canExtract,
|
||||
|
||||
// sharing
|
||||
'canShare' => $canShare, // legacy
|
||||
'canShareFile' => $canShareFileEff,
|
||||
'canShareFolder' => $canShareFoldEff,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -1,30 +1,28 @@
|
||||
<?php
|
||||
// public/api/folder/isEmpty.php
|
||||
// Fast ACL-aware peek for tree icons/chevrons
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
// Snapshot then release session lock so parallel requests don’t block
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||
'readOnly' => $_SESSION['readOnly'] ?? 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, '/');
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? '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),
|
||||
]);
|
||||
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);
|
||||
31
public/api/folder/listChildren.php
Normal file
31
public/api/folder/listChildren.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||
|
||||
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
|
||||
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
|
||||
|
||||
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
|
||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||
@@ -739,11 +739,167 @@ body {
|
||||
width: 100% !important;
|
||||
}#fileList table tr:nth-child(even) {
|
||||
background-color: transparent;
|
||||
}#fileList table tr:hover {
|
||||
background-color: #e0e0e0;
|
||||
}.dark-mode #fileList table tr:hover {
|
||||
background-color: #444;
|
||||
}#fileListTitle {
|
||||
}
|
||||
/* --- File list rows: match folder-tree hover/selected --- */
|
||||
:root {
|
||||
--filr-row-hover-bg: rgba(122,179,255,.14);
|
||||
--filr-row-selected-bg: rgba(122,179,255,.24);
|
||||
}
|
||||
|
||||
/* Let cell corners round like a pill */
|
||||
#fileList table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* ===== Reset any conflicting backgrounds (Bootstrap etc.) inside #fileList only ===== */
|
||||
#fileList table tbody tr,
|
||||
#fileList table tbody tr > td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Kill Bootstrap hover/zebra just for this table */
|
||||
#fileList table.table-hover tbody tr:hover > * { background-color: transparent !important; }
|
||||
#fileList table.table-striped > tbody > tr:nth-of-type(odd) > * { background-color: transparent !important; }
|
||||
|
||||
/* ===== Our look, scoped to the table we tagged in JS ===== */
|
||||
#fileList table.filr-table tbody tr,
|
||||
#fileList table.filr-table tbody td {
|
||||
transition: background-color .12s ease;
|
||||
}
|
||||
|
||||
/* Hover (when not selected) */
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
}
|
||||
|
||||
/* Selected (support a few legacy class names just in case) */
|
||||
#fileList table.filr-table tbody tr.selected > td,
|
||||
#fileList table.filr-table tbody tr.row-selected > td,
|
||||
#fileList table.filr-table tbody tr.selected-row > td,
|
||||
#fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
/* Rounded “pill” ends on hover/selected */
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Keyboard focus visibility */
|
||||
#fileList table.filr-table tbody tr:focus-within > td {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: -2px;
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.dark-mode #fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
}
|
||||
.dark-mode #fileList table.filr-table tbody tr.selected > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.row-selected > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.selected-row > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
}
|
||||
|
||||
#fileList table.filr-table {
|
||||
--bs-table-hover-color: inherit;
|
||||
--bs-table-striped-color: inherit;
|
||||
}
|
||||
|
||||
#fileList table.table-hover tbody tr:hover,
|
||||
#fileList table.table-hover tbody tr:hover > td {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.dark-mode #fileList table.filr-table tbody td a,
|
||||
.dark-mode #fileList table.filr-table tbody td a:hover {
|
||||
color: inherit !important;
|
||||
}
|
||||
:root{
|
||||
--filr-row-outline: rgba(122,179,255,.45);
|
||||
--filr-row-outline-hover: rgba(122,179,255,.35);
|
||||
}
|
||||
|
||||
#fileList table.filr-table > :not(caption) > * > * { border: 0 !important; }
|
||||
#fileList table.filr-table td,
|
||||
#fileList table.filr-table th { box-shadow: none !important; }
|
||||
|
||||
#fileList table.filr-table tbody tr.selected > td,
|
||||
#fileList table.filr-table tbody tr.row-selected > td,
|
||||
#fileList table.filr-table tbody tr.selected-row > td,
|
||||
#fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
}
|
||||
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||
box-shadow:
|
||||
inset 1px 0 0 var(--filr-row-outline),
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||
box-shadow:
|
||||
inset -1px 0 0 var(--filr-row-outline),
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
|
||||
}
|
||||
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child {
|
||||
box-shadow:
|
||||
inset 1px 0 0 var(--filr-row-outline-hover),
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child {
|
||||
box-shadow:
|
||||
inset -1px 0 0 var(--filr-row-outline-hover),
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
#fileList table.filr-table tbody tr:focus-within > td { outline: none; }
|
||||
#fileList table.filr-table tbody tr:focus-within > td:first-child,
|
||||
#fileList table.filr-table tbody tr:focus-within > td:last-child {
|
||||
outline: 2px solid #8ab4f8; outline-offset: -2px;
|
||||
}
|
||||
|
||||
#fileListTitle {
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
@@ -1000,7 +1156,7 @@ body {
|
||||
#fileListTitle {
|
||||
font-size: 1.8em;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 10px;
|
||||
}.file-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1185,7 +1341,7 @@ body {
|
||||
}.folder-tree.expanded {
|
||||
display: block;
|
||||
}.folder-item {
|
||||
margin: 4px 0;
|
||||
margin: 2px 0;
|
||||
display: block;
|
||||
}.folder-toggle {
|
||||
cursor: pointer;
|
||||
@@ -1432,8 +1588,6 @@ body {
|
||||
}.dark-mode table {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
}.dark-mode table tr:hover {
|
||||
background-color: #444;
|
||||
}.dark-mode #uploadProgressContainer .progress {
|
||||
background-color: #333;
|
||||
}.dark-mode #uploadProgressContainer .progress-bar {
|
||||
@@ -1876,34 +2030,74 @@ body {
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}.folder-strip-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-strip-container {
|
||||
display: flex;
|
||||
padding-top: 0px !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
gap: 10px 14px;
|
||||
align-content: flex-start; /* multi-line wrap stays top-aligned */
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
padding-top: 0px !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: 80px;
|
||||
align-items: center; /* horizontal (cross-axis) center */
|
||||
justify-content: center; /* vertical (main-axis) center */
|
||||
min-width: 0;
|
||||
gap: 2px !important;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}.folder-strip-container .folder-item i.material-icons {
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
}.folder-strip-container .folder-name {
|
||||
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease;
|
||||
}
|
||||
.folder-strip-container .folder-item .folder-svg {
|
||||
line-height: 0;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.folder-strip-container .folder-item .folder-svg svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
}
|
||||
.folder-strip-container .folder-name {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 80px;
|
||||
margin-top: 4px;
|
||||
}.folder-strip-container .folder-item i.material-icons {
|
||||
color: currentColor;
|
||||
}.folder-strip-container .folder-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}:root {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-item:hover {
|
||||
transform: translateY(-1px) scale(1.04);
|
||||
background-color: rgba(0, 0, 0, 0.04); /* light mode */
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
/* Dark mode hover */
|
||||
body.dark-mode .folder-strip-container .folder-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
/* Optional: keyboard focus */
|
||||
.folder-strip-container .folder-item:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--perm-caret: #444;
|
||||
}/* light */
|
||||
.dark-mode {
|
||||
@@ -2005,170 +2199,281 @@ body {
|
||||
|
||||
#downloadProgressBarOuter { height: 10px; }
|
||||
|
||||
/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */
|
||||
|
||||
/* Knobs (size, spacing, colors) */
|
||||
/* ===== Folder Tree – theme + structure ===== */
|
||||
#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 {
|
||||
/* Theme vars (light mode defaults) */
|
||||
--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 */
|
||||
--filr-paper-stroke: #9fb3d6; /* slightly darker for sharper paper */
|
||||
|
||||
/* Sizes */
|
||||
--row-h: 28px;
|
||||
--twisty: 24px;
|
||||
--twisty-gap: -5px;
|
||||
--icon-size: 24px;
|
||||
--icon-gap: 6px;
|
||||
--indent: 10px;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||
|
||||
/* visible “row” for each node */
|
||||
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||
|
||||
#folderTreeContainer .folder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
min-height: var(--row-h);
|
||||
height: auto;
|
||||
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 {
|
||||
/* Chevron + spacer (centered vertically) */
|
||||
#folderTreeContainer .folder-row > button.folder-toggle,
|
||||
#folderTreeContainer .folder-row > .folder-spacer {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid transparent; border-radius: 6px;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle::before {
|
||||
content: "▸"; /* closed */
|
||||
font-size: calc(var(--twisty) * 0.8);
|
||||
line-height: 1;
|
||||
content: "▸"; 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>) */
|
||||
> .folder-row > button.folder-toggle::before,
|
||||
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle:hover {
|
||||
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Row "pill" that hugs content and wraps */
|
||||
#folderTreeContainer .folder-option {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto; /* don't stretch full width */
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
line-height: 1.2; /* avoids baseline weirdness */
|
||||
gap: var(--icon-gap);
|
||||
height: auto;
|
||||
min-height: var(--row-h);
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
gap: var(--icon-gap);
|
||||
white-space: normal; /* allow wrapping */
|
||||
}
|
||||
|
||||
#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 */
|
||||
/* Label must be shrinkable so wrapping works */
|
||||
#folderTreeContainer .folder-label {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Icon box */
|
||||
#folderTreeContainer .folder-icon {
|
||||
flex: 0 0 var(--icon-size);
|
||||
width: var(--icon-size); height: var(--icon-size);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#folderTreeContainer .folder-icon svg {
|
||||
width: 100%; height: 100%; display: block;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
|
||||
/* Make folder tree outline match folder strip */
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: 1.1;
|
||||
stroke-width: .5;
|
||||
paint-order: fill stroke;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
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); }
|
||||
|
||||
#folderTreeContainer .folder-icon .paper {
|
||||
fill: var(--filr-paper-fill);
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .paper-fold { fill: var(--filr-paper-stroke); }
|
||||
#folderTreeContainer .folder-icon .paper-line {
|
||||
stroke: var(--filr-paper-stroke); stroke-width: 1.5;
|
||||
stroke-linecap: round; fill: none; opacity: .95;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lip-highlight {
|
||||
|
||||
fill: none; vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon,
|
||||
#folderTreeContainer .folder-label { transform: none !important; }
|
||||
|
||||
/* ===== File List Strip – color the shared folderSVG() ===== */
|
||||
.folder-strip-container .folder-svg svg {
|
||||
display: block;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
.folder-strip-container .folder-item {
|
||||
/* defaults — overridden per-tile via inline CSS vars set in JS */
|
||||
--filr-folder-front: #f6b84e;
|
||||
--filr-folder-back: #ffd36e;
|
||||
--filr-folder-stroke: #a87312;
|
||||
}
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: .5;
|
||||
paint-order: fill stroke;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-svg .folder-front { color: var(--filr-folder-front); }
|
||||
.folder-strip-container .folder-svg .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
.folder-strip-container .folder-svg .paper {
|
||||
fill: #fff;
|
||||
stroke: #b2c2db; /* light mode */
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.folder-strip-container .folder-svg .paper-fold { fill: #b2c2db; }
|
||||
.folder-strip-container .folder-svg .paper-line {
|
||||
stroke: #b2c2db; stroke-width: 1; stroke-linecap: round; fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.folder-strip-container .folder-svg .lip-highlight {
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back,
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back,
|
||||
#folderTreeContainer .folder-icon .lip-highlight,
|
||||
.folder-strip-container .folder-svg .lip-highlight {
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Make sure we’re not forcing crispEdges anywhere */
|
||||
.folder-strip-container .folder-svg svg,
|
||||
#folderTreeContainer .folder-icon svg { shape-rendering: geometricPrecision !important; }
|
||||
|
||||
@media (max-resolution: 1.5dppx) {
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back { stroke-width: .6; }
|
||||
}
|
||||
|
||||
|
||||
/* Scribble (the handwriting line) */
|
||||
#folderTreeContainer .folder-icon .paper-ink,
|
||||
.folder-strip-container .folder-svg .paper-ink {
|
||||
stroke: #4da3ff;
|
||||
stroke-width: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
opacity: .85;
|
||||
paint-order: normal;
|
||||
|
||||
}
|
||||
|
||||
/* tree @ 24px icon */
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back,
|
||||
#folderTreeContainer .folder-icon .paper-line,
|
||||
#folderTreeContainer .folder-icon .paper-ink,
|
||||
#folderTreeContainer .folder-icon .lip-highlight { stroke-width: .6px; }
|
||||
|
||||
/* strip @ 48px icon (2× bigger), halve stroke width to look the same */
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back,
|
||||
.folder-strip-container .folder-svg .paper-line,
|
||||
.folder-strip-container .folder-svg .paper-ink,
|
||||
.folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; }
|
||||
/* Locked folder look (keep subtle but clear) */
|
||||
#folderTreeContainer .folder-option.locked {
|
||||
opacity: .9;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Padlock styling inside the SVG */
|
||||
#folderTreeContainer .folder-icon .lock-body {
|
||||
fill: var(--filr-folder-stroke, #6b6b6b);
|
||||
opacity: .95;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lock-shackle {
|
||||
fill: none;
|
||||
stroke: var(--filr-folder-stroke, #6b6b6b);
|
||||
stroke-width: 1.4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lock-keyhole {
|
||||
fill: rgba(0,0,0,.28);
|
||||
}
|
||||
body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole {
|
||||
fill: rgba(255,255,255,.28);
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more {
|
||||
padding: 4px 0 4px 28px; /* indent to line up with rows */
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost {
|
||||
width: calc(100% - 8px);
|
||||
margin: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--tree-ghost-border);
|
||||
background: var(--tree-ghost-bg);
|
||||
color: var(--tree-ghost-fg);
|
||||
padding: 6px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost:hover {
|
||||
background: var(--tree-ghost-hover-bg);
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* tiny spinner when busy */
|
||||
#folderTreeContainer li.load-more > .btn[aria-busy="true"]::before {
|
||||
content: "";
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
display: inline-block;
|
||||
animation: filr-spin .8s linear infinite;
|
||||
}
|
||||
@keyframes filr-spin { to { transform: rotate(360deg); } }
|
||||
@@ -277,14 +277,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
||||
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
|
||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
||||
subfolder.</li>
|
||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
||||
the appropriate button.</li>
|
||||
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
|
||||
<style>
|
||||
/* Dark mode polish */
|
||||
body.dark-mode #folderHelpTooltip {
|
||||
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
|
||||
}
|
||||
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
|
||||
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
|
||||
</style>
|
||||
<ul class="folder-help-list">
|
||||
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
|
||||
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.</li>
|
||||
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
|
||||
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
|
||||
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
|
||||
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
|
||||
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
|
||||
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,6 +365,10 @@
|
||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_folder">Create folder</span>
|
||||
</li>
|
||||
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="upload">Upload file(s)</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Create File Modal -->
|
||||
@@ -494,6 +508,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Modal -->
|
||||
<div id="uploadModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:900px;width:92vw;">
|
||||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 style="margin:0;">Upload</h3>
|
||||
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- we will MOVE #uploadCard into here while open -->
|
||||
<div id="uploadModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
window.__pendingDropData = null;
|
||||
|
||||
function waitFor(selector, timeout = 1200) {
|
||||
return new Promise(resolve => {
|
||||
const t0 = performance.now();
|
||||
(function tick() {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) return resolve(el);
|
||||
if (performance.now() - t0 >= timeout) return resolve(null);
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||
const _nativeFetch = window.fetch.bind(window);
|
||||
|
||||
@@ -84,25 +98,53 @@ export function initializeApp() {
|
||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||
initTagSearch();
|
||||
|
||||
|
||||
// Hook DnD relay from fileList area into upload area
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
const fileListArea = document.getElementById('fileList');
|
||||
|
||||
if (fileListArea) {
|
||||
let hoverTimer = null;
|
||||
|
||||
fileListArea.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.add('drop-hover');
|
||||
// (optional) auto-open after brief hover so users see the drop target
|
||||
if (!hoverTimer) {
|
||||
hoverTimer = setTimeout(() => {
|
||||
if (typeof window.openUploadModal === 'function') window.openUploadModal();
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
fileListArea.addEventListener('dragleave', () => {
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
});
|
||||
fileListArea.addEventListener('drop', e => {
|
||||
|
||||
fileListArea.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||
dataTransfer: e.dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
|
||||
// 1) open the same modal that the Create menu uses
|
||||
openUploadModal();
|
||||
// 2) wait until the upload area exists *in the modal*, then relay the drop
|
||||
// Prefer a scoped selector first to avoid duplicate IDs.
|
||||
const uploadArea =
|
||||
(await waitFor('#uploadModal #uploadDropArea')) ||
|
||||
(await waitFor('#uploadDropArea'));
|
||||
if (!uploadArea) return;
|
||||
|
||||
try {
|
||||
// Many browsers make dataTransfer read-only; we try the direct attach first
|
||||
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
|
||||
uploadArea.dispatchEvent(relay);
|
||||
} catch {
|
||||
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
|
||||
window.__pendingDropData = e.dataTransfer || null;
|
||||
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
||||
|
||||
export function buildFileTableHeader(sortOrder) {
|
||||
return `
|
||||
<table class="table">
|
||||
<table class="table filr-table table-hover table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
|
||||
const row = checkbox.closest('tr');
|
||||
if (!row) return;
|
||||
if (checkbox.checked) {
|
||||
row.classList.add('row-selected');
|
||||
row.classList.add('row-selected', 'selected');
|
||||
} else {
|
||||
row.classList.remove('row-selected');
|
||||
row.classList.remove('row-selected', 'selected');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export function handleDeleteSelected(e) {
|
||||
showToast("no_files_selected");
|
||||
return;
|
||||
}
|
||||
|
||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||
const count = window.filesToDelete.length;
|
||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||
@@ -21,6 +20,52 @@ export function handleDeleteSelected(e) {
|
||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||
}
|
||||
|
||||
|
||||
// --- Upload modal "portal" support ---
|
||||
let _uploadCardSentinel = null;
|
||||
|
||||
export function openUploadModal() {
|
||||
const modal = document.getElementById('uploadModal');
|
||||
const body = document.getElementById('uploadModalBody');
|
||||
const card = document.getElementById('uploadCard'); // <-- your existing card
|
||||
window.openUploadModal = openUploadModal;
|
||||
window.__pendingDropData = null;
|
||||
if (!modal || !body || !card) {
|
||||
console.warn('Upload modal or upload card not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a hidden sentinel so we can put the card back in place later
|
||||
if (!_uploadCardSentinel) {
|
||||
_uploadCardSentinel = document.createElement('div');
|
||||
_uploadCardSentinel.id = 'uploadCardSentinel';
|
||||
_uploadCardSentinel.style.display = 'none';
|
||||
card.parentNode.insertBefore(_uploadCardSentinel, card);
|
||||
}
|
||||
|
||||
// Move the actual card node into the modal (keeps all existing listeners)
|
||||
body.appendChild(card);
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Focus the chooser for quick keyboard flow
|
||||
setTimeout(() => {
|
||||
const chooseBtn = document.getElementById('customChooseBtn');
|
||||
if (chooseBtn) chooseBtn.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
export function closeUploadModal() {
|
||||
const modal = document.getElementById('uploadModal');
|
||||
const card = document.getElementById('uploadCard');
|
||||
|
||||
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
|
||||
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
|
||||
}
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||
if (cancelDelete) {
|
||||
@@ -829,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt = document.getElementById('createFolderOption');
|
||||
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
@@ -853,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
});
|
||||
if (uploadOpt) {
|
||||
uploadOpt.addEventListener('click', () => {
|
||||
if (menu) menu.style.display = 'none';
|
||||
openUploadModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Close buttons / backdrop
|
||||
const upModal = document.getElementById('uploadModal');
|
||||
const closeX = document.getElementById('closeUploadModal');
|
||||
|
||||
if (closeX) closeX.addEventListener('click', closeUploadModal);
|
||||
|
||||
// click outside content to close
|
||||
if (upModal) {
|
||||
upModal.addEventListener('click', (e) => {
|
||||
if (e.target === upModal) closeUploadModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
|
||||
closeUploadModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.renameFile = renameFile;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -318,7 +318,19 @@ const translations = {
|
||||
"reset_default": "Reset",
|
||||
"save_color": "Save",
|
||||
"folder_color_saved": "Folder color saved.",
|
||||
"folder_color_cleared": "Folder color reset."
|
||||
"folder_color_cleared": "Folder color reset.",
|
||||
"load_more": "Load more",
|
||||
"loading": "Loading...",
|
||||
"no_access": "You do not have access to this resource.",
|
||||
"please_select_valid_folder": "Please select a valid folder.",
|
||||
"folder_help_click_view": "Click a folder in the tree to view its files.",
|
||||
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.",
|
||||
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
|
||||
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
|
||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -896,7 +896,8 @@ function initUpload() {
|
||||
dropArea.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = "";
|
||||
const dt = e.dataTransfer;
|
||||
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||
window.__pendingDropData = null;
|
||||
if (dt.items && dt.items.length > 0) {
|
||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.9.1';
|
||||
window.APP_VERSION = 'v1.9.5';
|
||||
|
||||
@@ -6,6 +6,7 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||
|
||||
class FolderController
|
||||
{
|
||||
@@ -31,6 +32,10 @@ class FolderController
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
|
||||
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
|
||||
}
|
||||
|
||||
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
|
||||
public static function stats(string $folder, string $user, array $perms): array
|
||||
{
|
||||
@@ -38,6 +43,161 @@ class FolderController
|
||||
return FolderModel::countVisible($folder, $user, $perms);
|
||||
}
|
||||
|
||||
/** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
|
||||
public static function capabilities(string $folder, string $username): array
|
||||
{
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
$perms = self::loadPermsFor($username);
|
||||
|
||||
$isAdmin = ACL::isAdmin($perms);
|
||||
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$disableUpload = !empty($perms['disableUpload']);
|
||||
|
||||
$isOwner = ACL::isOwner($username, $perms, $folder);
|
||||
|
||||
$inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);
|
||||
|
||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
|
||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||
|
||||
$canView = $canViewBase && $inScope;
|
||||
|
||||
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
|
||||
$canCreate = $gCreateBase && !$readOnly && $inScope;
|
||||
$canRename = $gRenameBase && !$readOnly && $inScope;
|
||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||
$canDeleteFile = $gDeleteBase && !$readOnly && $inScope;
|
||||
|
||||
$canDeleteFolder = !$readOnly && $inScope && (
|
||||
$isAdmin ||
|
||||
$isOwner ||
|
||||
ACL::canManage($username, $perms, $folder) ||
|
||||
$gDeleteBase // if your ACL::canDelete should also allow folder deletes
|
||||
);
|
||||
|
||||
$canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
|
||||
$canMoveIn = $canReceive;
|
||||
|
||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||
|
||||
$canShareEff = $canShareBase && $inScope;
|
||||
$canShareFile = $gShareFile && $inScope;
|
||||
$canShareFold = $gShareFolder && $inScope;
|
||||
|
||||
$isRoot = ($folder === 'root');
|
||||
$canMoveFolder = false;
|
||||
if ($isRoot) {
|
||||
$canRename = false;
|
||||
$canDelete = false;
|
||||
$canShareFold = false;
|
||||
} else {
|
||||
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||
&& !$readOnly;
|
||||
}
|
||||
|
||||
$owner = null;
|
||||
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
|
||||
|
||||
return [
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
'folderOnly' => $folderOnly,
|
||||
'readOnly' => $readOnly,
|
||||
'disableUpload' => $disableUpload,
|
||||
],
|
||||
'owner' => $owner,
|
||||
|
||||
'canView' => $canView,
|
||||
'canViewOwn' => $canViewOwn,
|
||||
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreate,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDeleteFile,
|
||||
'canDeleteFolder' => $canDeleteFolder,
|
||||
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canMove' => $canMoveIn, // legacy alias
|
||||
'canMoveFolder' => $canMoveFolder,
|
||||
|
||||
'canEdit' => $canEdit,
|
||||
'canCopy' => $canCopy,
|
||||
'canExtract' => $canExtract,
|
||||
|
||||
'canShare' => $canShareEff, // legacy umbrella
|
||||
'canShareFile' => $canShareFile,
|
||||
'canShareFolder' => $canShareFold,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Private helpers (caps)
|
||||
----------------------------*/
|
||||
private static function loadPermsFor(string $u): array {
|
||||
try {
|
||||
if (function_exists('loadUserPermissions')) {
|
||||
$p = loadUserPermissions($u);
|
||||
return is_array($p) ? $p : [];
|
||||
}
|
||||
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
||||
$all = userModel::getUserPermissions();
|
||||
if (is_array($all)) {
|
||||
if (isset($all[$u])) return (array)$all[$u];
|
||||
$lk = strtolower($u);
|
||||
if (isset($all[$lk])) return (array)$all[$lk];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function boolFrom(array $a, string ...$keys): bool {
|
||||
foreach ($keys as $k) if (!empty($a[$k])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||
$pos = strrpos($f, '/');
|
||||
if ($pos === false) break;
|
||||
$f = substr($f, 0, $pos);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
|
||||
if ($isAdmin) return true;
|
||||
if (!$folderOnly) return true; // normal users: global scope
|
||||
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if ($f === 'root' || $f === '') {
|
||||
return self::isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||
return self::isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
private static function requireCsrf(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
@@ -1123,8 +1283,11 @@ class FolderController
|
||||
$map = FolderMeta::getMap();
|
||||
$out = [];
|
||||
foreach ($map as $folder => $hex) {
|
||||
$folder = FolderMeta::normalizeFolder($folder);
|
||||
if (ACL::canRead($user, $perms, $folder)) $out[$folder] = $hex;
|
||||
$folder = FolderMeta::normalizeFolder((string)$folder);
|
||||
if ($folder === 'root') continue; // don’t bother exposing root
|
||||
if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
|
||||
$out[$folder] = $hex;
|
||||
}
|
||||
}
|
||||
echo json_encode($out, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -1153,21 +1316,29 @@ class FolderController
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
|
||||
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
|
||||
$color = isset($body['color']) ? (string)$body['color'] : '';
|
||||
$raw = array_key_exists('color', $body) ? (string)$body['color'] : '';
|
||||
|
||||
// Treat “customize color” as rename-level capability (your convention)
|
||||
if (!ACL::canRename($user, $perms, $folder)) {
|
||||
if ($folder === 'root') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Cannot set color on root']);
|
||||
return;
|
||||
}
|
||||
|
||||
// >>> Require canEdit (not canRename) <<<
|
||||
if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = FolderMeta::setColor($folder, $color === '' ? null : $color);
|
||||
// empty string clears; non-empty must be valid #RGB or #RRGGBB
|
||||
$hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
|
||||
$res = FolderMeta::setColor($folder, $hex);
|
||||
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
echo json_encode(['error' => 'Invalid color']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,6 @@ class MediaController
|
||||
$f = trim((string)$f);
|
||||
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
|
||||
}
|
||||
private function validFolder($f): bool {
|
||||
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
|
||||
}
|
||||
private function validFile($f): bool {
|
||||
$f = basename((string)$f);
|
||||
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
|
||||
@@ -56,6 +53,24 @@ class MediaController
|
||||
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
|
||||
}
|
||||
|
||||
private function validFolder($f): bool {
|
||||
if ($f === 'root') return true;
|
||||
// Validate per-segment against your REGEX_FOLDER_NAME
|
||||
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
|
||||
if (!$parts) return false;
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** “View” means read OR read_own */
|
||||
private function canViewFolder(string $folder, string $username): bool {
|
||||
$perms = loadUserPermissions($username) ?: [];
|
||||
return ACL::canRead($username, $perms, $folder)
|
||||
|| ACL::canReadOwn($username, $perms, $folder);
|
||||
}
|
||||
|
||||
/** POST /api/media/updateProgress.php */
|
||||
public function updateProgress(): void {
|
||||
$this->jsonStart();
|
||||
@@ -67,15 +82,15 @@ class MediaController
|
||||
$d = $this->readJson();
|
||||
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
|
||||
$file = (string)($d['file'] ?? '');
|
||||
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
|
||||
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
|
||||
$seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
|
||||
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
|
||||
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
|
||||
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
|
||||
$clear = !empty($d['clear']);
|
||||
|
||||
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
if ($clear) {
|
||||
$ok = MediaModel::clearProgress($u, $folder, $file);
|
||||
@@ -102,7 +117,7 @@ class MediaController
|
||||
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
$row = MediaModel::getProgress($u, $folder, $file);
|
||||
$this->out(['state'=>$row]);
|
||||
@@ -123,7 +138,12 @@ class MediaController
|
||||
if (!$this->validFolder($folder)) {
|
||||
$this->out(['error'=>'Invalid folder'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
// Soft-fail for restricted users: avoid noisy console 403s
|
||||
if (!$this->canViewFolder($folder, $u)) {
|
||||
$this->out(['map' => []]); // 200 OK, no leakage
|
||||
return;
|
||||
}
|
||||
|
||||
$map = MediaModel::getFolderMap($u, $folder);
|
||||
$this->out(['map'=>$map]);
|
||||
|
||||
87
src/lib/FS.php
Normal file
87
src/lib/FS.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
// src/lib/FS.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
final class FS
|
||||
{
|
||||
/** Hidden/system names to ignore entirely */
|
||||
public static function IGNORE(): array {
|
||||
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||
}
|
||||
|
||||
/** App-specific names to skip from UI */
|
||||
public static function SKIP(): array {
|
||||
return ['trash','profile_pics'];
|
||||
}
|
||||
|
||||
public static function isSafeSegment(string $name): bool {
|
||||
if ($name === '.' || $name === '..') return false;
|
||||
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
|
||||
if (strpos($name, "\0") !== false) return false;
|
||||
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
|
||||
$len = mb_strlen($name);
|
||||
return $len > 0 && $len <= 255;
|
||||
}
|
||||
|
||||
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
|
||||
public static function safeReal(string $baseReal, string $p): ?string {
|
||||
$rp = realpath($p);
|
||||
if ($rp === false) return null;
|
||||
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if (strpos($rp2, $base) !== 0) return null;
|
||||
return rtrim($rp, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
|
||||
* $maxDepth intentionally small to avoid expensive scans.
|
||||
*/
|
||||
public static function hasReadableDescendant(
|
||||
string $baseReal,
|
||||
string $absPath,
|
||||
string $relPath,
|
||||
string $user,
|
||||
array $perms,
|
||||
int $maxDepth = 2
|
||||
): bool {
|
||||
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
|
||||
|
||||
$IGNORE = self::IGNORE();
|
||||
$SKIP = self::SKIP();
|
||||
|
||||
$items = @scandir($absPath) ?: [];
|
||||
foreach ($items as $child) {
|
||||
if ($child === '.' || $child === '..') continue;
|
||||
if ($child[0] === '.') continue;
|
||||
if (in_array($child, $IGNORE, true)) continue;
|
||||
if (!self::isSafeSegment($child)) continue;
|
||||
|
||||
$lower = strtolower($child);
|
||||
if (in_array($lower, $SKIP, true)) continue;
|
||||
|
||||
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
|
||||
if (!@is_dir($abs)) continue;
|
||||
|
||||
// Resolve symlink safely
|
||||
if (@is_link($abs)) {
|
||||
$safe = self::safeReal($baseReal, $abs);
|
||||
if ($safe === null || !is_dir($safe)) continue;
|
||||
$abs = $safe;
|
||||
}
|
||||
|
||||
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
|
||||
|
||||
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
|
||||
return true;
|
||||
}
|
||||
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||
|
||||
class FolderModel
|
||||
{
|
||||
@@ -10,44 +11,228 @@ class FolderModel
|
||||
* 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];
|
||||
}
|
||||
public static function countVisible(string $folder, string $user, array $perms): array
|
||||
{
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
||||
$canViewFolder = ACL::isAdmin($perms)
|
||||
|| ACL::canRead($user, $perms, $folder)
|
||||
|| ACL::canReadOwn($user, $perms, $folder);
|
||||
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
|
||||
|
||||
$base = realpath((string)UPLOAD_DIR);
|
||||
if ($base === false) return ['folders' => 0, 'files' => 0];
|
||||
|
||||
// Resolve target dir + ACL-relative prefix
|
||||
if ($folder === 'root') {
|
||||
$dir = $base;
|
||||
$relPrefix = '';
|
||||
} else {
|
||||
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||
foreach ($parts as $seg) {
|
||||
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
$dir = self::safeReal($base, $guess);
|
||||
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
|
||||
$relPrefix = implode('/', $parts);
|
||||
}
|
||||
|
||||
// Ignore lists (expandable)
|
||||
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||
$SKIP = ['trash', 'profile_pics'];
|
||||
|
||||
$entries = @scandir($dir);
|
||||
if ($entries === false) return ['folders' => 0, 'files' => 0];
|
||||
|
||||
$hasChildFolder = false;
|
||||
$hasFile = false;
|
||||
|
||||
// Cap scanning to avoid pathological dirs
|
||||
$MAX_SCAN = 4000;
|
||||
$scanned = 0;
|
||||
|
||||
foreach ($entries as $name) {
|
||||
if (++$scanned > $MAX_SCAN) break;
|
||||
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
if ($name[0] === '.') continue;
|
||||
if (in_array($name, $IGNORE, true)) continue;
|
||||
if (in_array(strtolower($name), $SKIP, true)) continue;
|
||||
if (!self::isSafeSegment($name)) continue;
|
||||
|
||||
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
|
||||
if (@is_dir($abs)) {
|
||||
// Symlink defense on children
|
||||
if (@is_link($abs)) {
|
||||
$safe = self::safeReal($base, $abs);
|
||||
if ($safe === null || !is_dir($safe)) continue;
|
||||
}
|
||||
// Only count child dirs the user can view (admin/read/read_own)
|
||||
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
||||
if (
|
||||
ACL::isAdmin($perms)
|
||||
|| ACL::canRead($user, $perms, $childRel)
|
||||
|| ACL::canReadOwn($user, $perms, $childRel)
|
||||
) {
|
||||
$hasChildFolder = true;
|
||||
}
|
||||
} elseif (@is_file($abs)) {
|
||||
// Any file present is enough for the "files" flag once the folder itself is viewable
|
||||
$hasFile = true;
|
||||
}
|
||||
|
||||
if ($hasChildFolder && $hasFile) break; // early exit
|
||||
}
|
||||
|
||||
return [
|
||||
'folders' => $hasChildFolder ? 1 : 0,
|
||||
'files' => $hasFile ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/* Helpers (private) */
|
||||
private static function isSafeSegment(string $name): bool
|
||||
{
|
||||
if ($name === '.' || $name === '..') return false;
|
||||
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
|
||||
if (strpos($name, "\0") !== false) return false;
|
||||
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
|
||||
$len = mb_strlen($name);
|
||||
return $len > 0 && $len <= 255;
|
||||
}
|
||||
private static function safeReal(string $baseReal, string $p): ?string
|
||||
{
|
||||
$rp = realpath($p);
|
||||
if ($rp === false) return null;
|
||||
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if (strpos($rp2, $base) !== 0) return null;
|
||||
return rtrim($rp, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
|
||||
{
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
$limit = max(1, min(2000, $limit));
|
||||
$cursor = ($cursor !== null && $cursor !== '') ? $cursor : null;
|
||||
|
||||
$baseReal = realpath((string)UPLOAD_DIR);
|
||||
if ($baseReal === false) return ['items' => [], 'nextCursor' => null];
|
||||
|
||||
// Resolve target directory
|
||||
if ($folder === 'root') {
|
||||
$dirReal = $baseReal;
|
||||
$relPrefix = 'root';
|
||||
} else {
|
||||
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||
foreach ($parts as $seg) {
|
||||
if (!FS::isSafeSegment($seg)) return ['items'=>[], 'nextCursor'=>null];
|
||||
}
|
||||
$relPrefix = implode('/', $parts);
|
||||
$dirGuess = $baseReal . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
$dirReal = FS::safeReal($baseReal, $dirGuess);
|
||||
if ($dirReal === null || !is_dir($dirReal)) return ['items'=>[], 'nextCursor'=>null];
|
||||
}
|
||||
|
||||
$IGNORE = FS::IGNORE();
|
||||
$SKIP = FS::SKIP(); // lowercased names to skip (e.g. 'trash', 'profile_pics')
|
||||
|
||||
$entries = @scandir($dirReal);
|
||||
if ($entries === false) return ['items'=>[], 'nextCursor'=>null];
|
||||
|
||||
$rows = []; // each: ['name'=>..., 'locked'=>bool, 'hasSubfolders'=>bool?, 'nonEmpty'=>bool?]
|
||||
foreach ($entries as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
if ($item[0] === '.') continue;
|
||||
if (in_array($item, $IGNORE, true)) continue;
|
||||
if (!FS::isSafeSegment($item)) continue;
|
||||
|
||||
$lower = strtolower($item);
|
||||
if (in_array($lower, $SKIP, true)) continue;
|
||||
|
||||
$full = $dirReal . DIRECTORY_SEPARATOR . $item;
|
||||
if (!@is_dir($full)) continue;
|
||||
|
||||
// Symlink defense
|
||||
if (@is_link($full)) {
|
||||
$safe = FS::safeReal($baseReal, $full);
|
||||
if ($safe === null || !is_dir($safe)) continue;
|
||||
$full = $safe;
|
||||
}
|
||||
|
||||
// ACL-relative path (for checks)
|
||||
$rel = ($relPrefix === 'root') ? $item : $relPrefix . '/' . $item;
|
||||
$canView = ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel);
|
||||
$locked = !$canView;
|
||||
|
||||
// ---- quick per-child stats (single-level scan, early exit) ----
|
||||
$hasSubs = false; // at least one subdirectory
|
||||
$nonEmpty = false; // any direct entry (file or folder)
|
||||
try {
|
||||
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
|
||||
foreach ($it as $child) {
|
||||
$name = $child->getFilename();
|
||||
if (!$name) continue;
|
||||
if ($name[0] === '.') continue;
|
||||
if (!FS::isSafeSegment($name)) continue;
|
||||
if (in_array(strtolower($name), $SKIP, true)) continue;
|
||||
|
||||
$nonEmpty = true;
|
||||
|
||||
$isDir = $child->isDir();
|
||||
if (!$isDir && $child->isLink()) {
|
||||
$linkReal = FS::safeReal($baseReal, $child->getPathname());
|
||||
$isDir = ($linkReal !== null && is_dir($linkReal));
|
||||
}
|
||||
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// keep defaults
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
if ($locked) {
|
||||
// Show a locked row ONLY when this folder has a readable descendant
|
||||
if (FS::hasReadableDescendant($baseReal, $full, $rel, $user, $perms, 2)) {
|
||||
$rows[] = [
|
||||
'name' => $item,
|
||||
'locked' => true,
|
||||
'hasSubfolders' => $hasSubs, // fine to keep structural chevrons
|
||||
// nonEmpty intentionally omitted for locked nodes
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$rows[] = [
|
||||
'name' => $item,
|
||||
'locked' => false,
|
||||
'hasSubfolders' => $hasSubs,
|
||||
'nonEmpty' => $nonEmpty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// natural order + cursor pagination
|
||||
usort($rows, fn($a, $b) => strnatcasecmp($a['name'], $b['name']));
|
||||
$start = 0;
|
||||
if ($cursor !== null) {
|
||||
$n = count($rows);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
if (strnatcasecmp($rows[$i]['name'], $cursor) > 0) { $start = $i; break; }
|
||||
$start = $i + 1;
|
||||
}
|
||||
}
|
||||
$page = array_slice($rows, $start, $limit);
|
||||
$nextCursor = null;
|
||||
if ($start + count($page) < count($rows)) {
|
||||
$last = $page[count($page)-1];
|
||||
$nextCursor = $last['name'];
|
||||
}
|
||||
|
||||
return ['items' => $page, 'nextCursor' => $nextCursor];
|
||||
}
|
||||
|
||||
/** Load the folder → owner map. */
|
||||
public static function getFolderOwners(): array
|
||||
@@ -213,40 +398,42 @@ class FolderModel
|
||||
// -------- Normalize incoming values (use ONLY the parameters) --------
|
||||
$folderName = trim((string)$folderName);
|
||||
$parentIn = trim((string)$parent);
|
||||
|
||||
|
||||
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
|
||||
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
|
||||
$normalized = ACL::normalizeFolder($folderName);
|
||||
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
|
||||
if (
|
||||
$normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)
|
||||
) {
|
||||
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
||||
$folderName = basename($normalized);
|
||||
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
||||
}
|
||||
|
||||
|
||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||
$folderName = trim($folderName);
|
||||
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
|
||||
|
||||
if ($folderName === '') return ['success' => false, 'error' => 'Folder name required'];
|
||||
|
||||
// ACL key for new folder
|
||||
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
|
||||
// -------- Compose filesystem paths --------
|
||||
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
||||
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
||||
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
||||
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
|
||||
// -------- Exists / sanity checks --------
|
||||
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
|
||||
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
|
||||
|
||||
if (!is_dir($parentAbs)) return ['success' => false, 'error' => 'Parent folder does not exist'];
|
||||
if (is_dir($newAbs)) return ['success' => false, 'error' => 'Folder already exists'];
|
||||
|
||||
// -------- Create directory --------
|
||||
if (!@mkdir($newAbs, 0775, true)) {
|
||||
$err = error_get_last();
|
||||
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
|
||||
return ['success' => false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': ' . $err['message']) : '')];
|
||||
}
|
||||
|
||||
|
||||
// -------- Seed ACL --------
|
||||
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
||||
try {
|
||||
@@ -265,9 +452,9 @@ class FolderModel
|
||||
} catch (Throwable $e) {
|
||||
// Roll back FS if ACL seeding fails
|
||||
@rmdir($newAbs);
|
||||
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||||
return ['success' => false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
|
||||
return ['success' => true, 'folder' => $newKey];
|
||||
}
|
||||
|
||||
@@ -318,7 +505,7 @@ class FolderModel
|
||||
|
||||
// Validate names (per-segment)
|
||||
foreach ([$oldFolder, $newFolder] as $f) {
|
||||
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
|
||||
$parts = array_filter(explode('/', $f), fn($p) => $p !== '');
|
||||
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
@@ -333,7 +520,7 @@ class FolderModel
|
||||
$base = realpath(UPLOAD_DIR);
|
||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||
|
||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p !== '');
|
||||
$newRel = implode('/', $newParts);
|
||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||
|
||||
@@ -508,7 +695,7 @@ class FolderModel
|
||||
return [
|
||||
"record" => $record,
|
||||
"folder" => $relative,
|
||||
"realFolderPath"=> $realFolderPath,
|
||||
"realFolderPath" => $realFolderPath,
|
||||
"files" => $filesOnPage,
|
||||
"currentPage" => $currentPage,
|
||||
"totalPages" => $totalPages
|
||||
@@ -532,7 +719,7 @@ class FolderModel
|
||||
}
|
||||
|
||||
$expires = time() + max(1, $expirationSeconds);
|
||||
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
$links = file_exists($shareFile)
|
||||
@@ -560,7 +747,7 @@ class FolderModel
|
||||
|
||||
// Build URL
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||
@@ -587,7 +774,7 @@ class FolderModel
|
||||
return ["error" => "This share link has expired."];
|
||||
}
|
||||
|
||||
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||||
[$realFolderPath,, $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||||
if ($err || !is_dir($realFolderPath)) {
|
||||
return ["error" => "Shared folder not found."];
|
||||
}
|
||||
@@ -615,8 +802,26 @@ class FolderModel
|
||||
// Max size & allowed extensions (mirror FileModel’s common types)
|
||||
$maxSize = 50 * 1024 * 1024; // 50 MB
|
||||
$allowedExtensions = [
|
||||
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
|
||||
'mp4','webm','mp3','mkv','csv','json','xml','md'
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'txt',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'mp4',
|
||||
'webm',
|
||||
'mp3',
|
||||
'mkv',
|
||||
'csv',
|
||||
'json',
|
||||
'xml',
|
||||
'md'
|
||||
];
|
||||
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
@@ -655,7 +860,7 @@ class FolderModel
|
||||
|
||||
// New safe filename
|
||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||
$newFilename= uniqid('', true) . "_" . $safeBase;
|
||||
$newFilename = uniqid('', true) . "_" . $safeBase;
|
||||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||||
|
||||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||||
@@ -697,4 +902,4 @@ class FolderModel
|
||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user