Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
Ryan
a031fc99c2 release(ci): harden release-on-version workflow; remove sleep/race, safer checkout, deterministic ref 2025-11-10 03:01:46 -05:00
github-actions[bot]
db73cf2876 chore(release): set APP_VERSION to v1.9.2 [skip ci] 2025-11-10 07:50:29 +00:00
Ryan
062f34dd3d release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback) 2025-11-10 02:50:19 -05:00
Ryan
63b24ba698 chore(doc): readme adjustments for webdav & security 2025-11-09 21:59:20 -05:00
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
31 changed files with 4448 additions and 2048 deletions

View File

@@ -6,160 +6,79 @@ on:
branches: ["master"]
paths:
- public/js/version.js
workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed]
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: |

View File

@@ -1,5 +1,162 @@
# Changelog
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
- UI / Icons
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
- Folder color & non-empty detection
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
- FileList strip
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
- Exports & helpers
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
- AppCore
- Update file upload DnD relay hook to `#fileList` (id rename).
- CSS tweaks
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
- CI/CD (release)
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
- Changelog highlights
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
- Real-time folder color propagation between views.
- Folder strip switched to SVG tiles with better layout + context actions.
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
---
## Changes 11/10/2025 (v1.9.2)
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
- New “Upload file(s)” action in Create menu:
- Adds `<li id="uploadOption">` to the dropdown.
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
- Drag & Drop from file list → Upload:
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
- On drop, waits until the modals #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 isnt present.
- No backend/API changes; CSRF/auth unchanged.
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
---
## Changes 11/9/2025 (v1.9.1)
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
### Highlights v1.9.1
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
- 📄 Folder icons auto-refresh when contents change (no full page reload).
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
- feat(colors): add per-folder color customization
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
- Read endpoint filters map by ACL::canRead before returning to the user.
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
- feat(ui): color-picker modal with live SVG folder preview
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
- feat(controls): “Color folder” button in Folder Management card
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
- ux(tree): make expansion state more predictable across refreshes
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
---
## Changes 11/9/2025 (v1.9.0)
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
feat(ui): modern folder tree
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
- Breadcrumb tweaks ( separators), hover/selected polish
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
feat(api): add /api/folder/isEmpty.php via controller/model
- public/api/folder/isEmpty.php delegates to FolderController::stats()
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
- Releases PHP session lock early to avoid parallel-request pileups
perf: cap concurrent “isEmpty” requests + timeouts
- Small concurrency limiter + fetch timeouts
- In-memory result & inflight caches for fewer network hits
fix(state): preserve user expand/collapse choices
- Respect saved folderTreeState; dont auto-expand unopened nodes
- Only show ancestors for visibility when navigating (no unwanted persists)
security: tighten .htaccess while enabling WebDAV
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
- AcceptPathInfo On; keep path-aware dotfile denial
refactor: move count logic to model; thin controller action
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
---
## Changes 11/8/2025 (v1.8.13)
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
- header dock: show dock whenever icons exist (and on collapse); hide when empty
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
---
## Changes 11/8/2025 (v1.8.12)
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons

View File

@@ -21,15 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
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 youre 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:**
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
![filerise-v1 8 10-latest](https://github.com/user-attachments/assets/f966d66b-b13b-473b-b266-3ab316740a84)
![filerise-v1 9 0](https://github.com/user-attachments/assets/a346dd8a-eef1-4180-8140-4c1c08e6026e)
---
@@ -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 youre 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 youre running ≤1.4.x, please upgrade.
@@ -445,18 +430,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
### ONLYOFFICE integration
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
#### Security / CSP
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
@@ -478,7 +456,7 @@ If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
---

View File

@@ -4,6 +4,9 @@
Options -Indexes -Multiviews
DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c>
# Block direct access to dotfiles like .env, .gitignore, etc.
@@ -24,10 +27,14 @@ RewriteRule - - [L]
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
# 2) Deny direct access to PHP outside /api/
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
RewriteCond %{REQUEST_URI} !^/api/
RewriteRule \.php$ - [F]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)
# - allow /api.php (ReDoc/spec page)
# - allow /webdav.php (SabreDAV front)
RewriteCond %{REQUEST_URI} !^/api/ [NC]
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
RewriteRule \.php$ - [F,L]
# 3) Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->getFolderColors(); // echoes JSON + status codes
} catch (Throwable $e) {
error_log('getFolderColors failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

View File

@@ -0,0 +1,30 @@
<?php
// public/api/folder/isEmpty.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
];
@session_write_close();
// Input
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
// Delegate to controller (model handles ACL + path safety)
$result = FolderController::stats($folder, $user, $perms);
// Always return a compact JSON object like before
echo json_encode([
'folders' => (int)($result['folders'] ?? 0),
'files' => (int)($result['files'] ?? 0),
]);

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
} catch (Throwable $e) {
error_log('saveFolderColor failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

View File

@@ -62,6 +62,51 @@ body {
@media (max-width: 600px) {
.zones-toggle { left: 85px !important; }
}
/* Optional tokens */
:root{
--filr-accent-500:#008CB4; /* base */
--filr-accent-600:#00789A; /* hover */
--filr-accent-700:#006882; /* active/border */
--filr-accent-ring:rgba(0,140,180,.4);
}
/* Button */
.btn-color-folder{
display:inline-flex; align-items:center; gap:6px;
background:var(--filr-accent-500);
border:1px solid var(--filr-accent-700);
color:#fff; /* ensure white text */
}
.btn-color-folder .material-icons{
color:currentColor; /* makes icon white too */
}
.btn-color-folder:hover,
.btn-color-folder:focus-visible{
background:var(--filr-accent-600);
border-color:var(--filr-accent-700);
}
.btn-color-folder:active{
background:var(--filr-accent-700);
}
.btn-color-folder:focus-visible{
outline:2px solid var(--filr-accent-ring);
outline-offset:2px;
}
/* Dark mode: start slightly deeper so it doesn't glow */
.dark-mode .btn-color-folder{
background:var(--filr-accent-600);
border-color:var(--filr-accent-700);
color:#fff;
}
.dark-mode .btn-color-folder:hover,
.dark-mode .btn-color-folder:focus-visible{
background:var(--filr-accent-700);
}
/* ===========================================================
HEADER & NAVIGATION
=========================================================== */
@@ -142,13 +187,13 @@ body {
border-radius: 4px !important;
padding: 6px 10px !important;
}
/* make the drop zone fill leftover space and right-align its own icons */
#headerDropArea.header-drop-zone{
display: flex;
justify-content: flex-end;
min-width: 100px;
}
#headerDropArea.header-drop-zone{
display: flex;
justify-content: flex-end; /* buttons to the right */
align-items: center;
min-height: 40px; /* so the label has room */
}
.header-buttons button:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
@@ -694,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;
@@ -801,14 +1002,17 @@ body {
}
#uploadForm {
display: none;
}.folder-actions {
display: flex;
flex-wrap: nowrap;
padding-left: 8px;
}
.folder-actions {
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
padding-top: 10px;
}@media (min-width: 600px) and (max-width: 992px) {
gap: 2px;
flex-wrap: wrap;
white-space: normal;
margin: 0; /* no hacks needed */
}
@media (min-width: 600px) and (max-width: 992px) {
.folder-actions {
white-space: nowrap;
}}
@@ -821,10 +1025,8 @@ body {
}.folder-actions .material-icons {
font-size: 24px;
vertical-align: -2px;
}.folder-actions .btn + .btn {
margin-left: 6px;
}.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.1;
border-radius: 6px;
@@ -834,7 +1036,7 @@ body {
transition: transform 120ms ease, box-shadow 120ms ease;
will-change: transform;
}.folder-actions .material-icons {
font-size: 24px;
font-size: 20px;
vertical-align: -2px;
transition: transform 120ms ease;
}.folder-actions .btn:hover,
@@ -954,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;
@@ -1132,14 +1334,14 @@ body {
border-radius: 4px;
}.folder-tree {
list-style-type: none;
padding-left: 10px;
padding-left: 5px;
margin: 0;
}.folder-tree.collapsed {
display: none;
}.folder-tree.expanded {
display: block;
}.folder-item {
margin: 4px 0;
margin: 2px 0;
display: block;
}.folder-toggle {
cursor: pointer;
@@ -1149,9 +1351,10 @@ body {
text-align: right;
}.folder-indent-placeholder {
display: inline-block;
width: 30px;
width: 5px;
}#folderTreeContainer {
display: block;
margin-left: 10px;
}.folder-option {
cursor: pointer;
}.folder-option:hover {
@@ -1385,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 {
@@ -1532,7 +1733,16 @@ body {
.drag-header.active {
width: 350px;
height: 750px;
}.main-column {
}
/* Fixed-width sidebar (always 350px) */
#sidebarDropArea{
width: 350px;
min-width: 350px;
max-width: 350px;
flex: 0 0 350px;
box-sizing: border-box;
}
.main-column {
flex: 1;
transition: margin-left 0.3s ease;
}#uploadFolderRow {
@@ -1600,8 +1810,8 @@ body {
}#sidebarDropArea,
#uploadFolderRow {
background-color: transparent;
}.dark-mode #sidebarDropArea,
}
.dark-mode #sidebarDropArea,
.dark-mode #uploadFolderRow {
background-color: transparent;
}.dark-mode #sidebarDropArea.highlight,
@@ -1615,8 +1825,6 @@ body {
border: none !important;
}.dragging:focus {
outline: none;
}#sidebarDropArea > .card {
margin-bottom: 1rem;
}.card {
background-color: #fff;
color: #000;
@@ -1634,6 +1842,7 @@ body {
}.custom-folder-card-body {
padding-top: 5px !important;
padding-right: 0 !important;
padding-left: 0 !important;
}#addUserModal,
#removeUserModal {
z-index: 5000 !important;
@@ -1713,8 +1922,9 @@ body {
border: 2px dashed #555;
color: #fff;
}.header-drop-zone.drag-active:empty::before {
content: "Drop";
content: "Drop Zone";
font-size: 10px;
padding-right: 6px;
color: #aaa;
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
#fileList tbody tr.clickable-row {
@@ -1820,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 {
@@ -1947,4 +2197,219 @@ body {
text-overflow: ellipsis;
}
#downloadProgressBarOuter { height: 10px; }
#downloadProgressBarOuter { height: 10px; }
/* ===== Folder Tree theme + structure ===== */
#folderTreeContainer {
/* Theme vars (light mode defaults) */
--filr-folder-front: #f6b84e;
--filr-folder-back: #ffd36e;
--filr-folder-stroke:#a87312;
--filr-paper-fill: #ffffff;
--filr-paper-stroke: #9fb3d6; /* slightly darker for sharper paper */
/* Sizes */
--row-h: 28px;
--twisty: 24px;
--twisty-gap: -5px;
--icon-size: 24px;
--icon-gap: 6px;
--indent: 10px;
}
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
#folderTreeContainer .folder-row {
position: relative;
display: flex;
align-items: center;
min-height: var(--row-h);
height: auto;
padding-left: calc(var(--twisty) + var(--twisty-gap));
}
/* Chevron + spacer (centered vertically) */
#folderTreeContainer .folder-row > button.folder-toggle,
#folderTreeContainer .folder-row > .folder-spacer {
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: var(--twisty); height: var(--twisty);
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid transparent; border-radius: 6px;
background: transparent; cursor: pointer;
}
#folderTreeContainer .folder-row > button.folder-toggle::before {
content: "▸"; font-size: calc(var(--twisty) * 0.8); line-height: 1;
}
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
> .folder-row > button.folder-toggle::before,
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
#folderTreeContainer .folder-row > button.folder-toggle:hover {
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
}
/* Row "pill" that hugs content and wraps */
#folderTreeContainer .folder-option {
display: inline-flex;
flex: 0 1 auto; /* don't stretch full width */
align-items: center;
gap: var(--icon-gap);
height: auto;
min-height: var(--row-h);
padding: 0 8px;
border-radius: 8px;
line-height: 1.2;
user-select: none;
max-width: 100%;
white-space: normal; /* allow wrapping */
}
#folderTreeContainer .folder-option:hover {
background: rgba(122,179,255,.14);
}
#folderTreeContainer .folder-option.selected {
background: rgba(122,179,255,.24);
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
}
/* Label must be shrinkable so wrapping works */
#folderTreeContainer .folder-label {
flex: 1 1 120px;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.2;
}
/* Icon box */
#folderTreeContainer .folder-icon {
flex: 0 0 var(--icon-size);
width: var(--icon-size); height: var(--icon-size);
display: inline-flex; align-items: center; justify-content: center;
}
#folderTreeContainer .folder-icon svg {
width: 100%; height: 100%; display: block;
shape-rendering: geometricPrecision;
}
/* Make folder tree outline match folder strip */
#folderTreeContainer .folder-icon .folder-front,
#folderTreeContainer .folder-icon .folder-back {
fill: currentColor;
stroke: var(--filr-folder-stroke);
stroke-width: .5;
paint-order: fill stroke;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
#folderTreeContainer .folder-icon .paper {
fill: var(--filr-paper-fill);
stroke: var(--filr-paper-stroke);
stroke-width: 1.5;
paint-order: stroke fill;
}
#folderTreeContainer .folder-icon .paper-fold { fill: var(--filr-paper-stroke); }
#folderTreeContainer .folder-icon .paper-line {
stroke: var(--filr-paper-stroke); stroke-width: 1.5;
stroke-linecap: round; fill: none; opacity: .95;
}
#folderTreeContainer .folder-icon .lip-highlight {
stroke: #ffffff; stroke-opacity: .35; stroke-width: .9;
fill: none; vector-effect: non-scaling-stroke;
}
#folderTreeContainer .folder-icon,
#folderTreeContainer .folder-label { transform: none !important; }
/* ===== File List Strip color the shared folderSVG() ===== */
.folder-strip-container .folder-svg svg {
display: block;
shape-rendering: crispEdges;
}
.folder-strip-container .folder-item {
/* defaults — overridden per-tile via inline CSS vars set in JS */
--filr-folder-front: #f6b84e;
--filr-folder-back: #ffd36e;
--filr-folder-stroke: #a87312;
}
.folder-strip-container .folder-svg .folder-front,
.folder-strip-container .folder-svg .folder-back {
fill: currentColor;
stroke: var(--filr-folder-stroke);
stroke-width: .5;
paint-order: fill stroke;
stroke-linejoin: round;
stroke-linecap: round;
}
.folder-strip-container .folder-svg .folder-front { color: var(--filr-folder-front); }
.folder-strip-container .folder-svg .folder-back { color: var(--filr-folder-back); }
.folder-strip-container .folder-svg .paper {
fill: #fff;
stroke: #b2c2db; /* light mode */
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.folder-strip-container .folder-svg .paper-fold { fill: #b2c2db; }
.folder-strip-container .folder-svg .paper-line {
stroke: #b2c2db; stroke-width: 1; stroke-linecap: round; fill: none;
vector-effect: non-scaling-stroke;
}
.folder-strip-container .folder-svg .lip-highlight {
stroke: rgba(255,255,255,.45); stroke-width: .8; fill: none;
vector-effect: non-scaling-stroke;
}
#folderTreeContainer .folder-icon .folder-front,
#folderTreeContainer .folder-icon .folder-back,
.folder-strip-container .folder-svg .folder-front,
.folder-strip-container .folder-svg .folder-back,
#folderTreeContainer .folder-icon .lip-highlight,
.folder-strip-container .folder-svg .lip-highlight {
stroke-linejoin: round;
stroke-linecap: round;
}
/* Make sure were 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; }

View File

@@ -252,6 +252,9 @@
</div>
</div>
</div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i>
@@ -352,6 +355,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 -->
@@ -491,6 +498,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">&times;</span>
</div>
<div class="modal-body">
<!-- we will MOVE #uploadCard into here while open -->
<div id="uploadModalBody"></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -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 }));
}
});
}

View File

@@ -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');
}
}

View File

@@ -41,7 +41,6 @@ function readLayout() {
function writeLayout(layout) {
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {}));
}
function setLayoutFor(cardId, zoneId) {
const layout = readLayout();
layout[cardId] = zoneId;
@@ -93,6 +92,7 @@ function removeHeaderIconForCard(card) {
function insertCardInHeader(card) {
const host = getHeaderDropArea();
if (!host) return;
// Ensure hidden container exists to park real cards while icon-visible.
let hidden = $('hiddenCardsContainer');
if (!hidden) {
@@ -110,10 +110,10 @@ function insertCardInHeader(card) {
iconButton.style.border = 'none';
iconButton.style.background = 'none';
iconButton.style.cursor = 'pointer';
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${card.id === 'uploadCard' ? 'cloud_upload'
: card.id === 'folderManagementCard' ? 'folder'
: 'insert_drive_file'
}</i>`;
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${
card.id === 'uploadCard' ? 'cloud_upload' :
card.id === 'folderManagementCard' ? 'folder' : 'insert_drive_file'
}</i>`;
iconButton.cardElement = card;
card.headerIconButton = iconButton;
@@ -150,8 +150,8 @@ function insertCardInHeader(card) {
function showModal() {
ensureModal();
if (!modal.contains(card)) {
let hidden = $('hiddenCardsContainer');
if (hidden && hidden.contains(card)) hidden.removeChild(card);
const hiddenNow = $('hiddenCardsContainer');
if (hiddenNow && hiddenNow.contains(card)) hiddenNow.removeChild(card);
card.style.width = '';
card.style.minWidth = '';
modal.appendChild(card);
@@ -163,8 +163,8 @@ function insertCardInHeader(card) {
if (!modal) return;
modal.style.visibility = 'hidden';
modal.style.opacity = '0';
const hidden = $('hiddenCardsContainer');
if (hidden && modal.contains(card)) hidden.appendChild(card);
const hiddenNow = $('hiddenCardsContainer');
if (hiddenNow && modal.contains(card)) hiddenNow.appendChild(card);
}
function maybeHide() {
setTimeout(() => {
@@ -181,6 +181,8 @@ function insertCardInHeader(card) {
});
host.appendChild(iconButton);
// make sure the dock is visible when icons exist
showHeaderDockPersistent();
saveHeaderOrder();
}
@@ -227,6 +229,10 @@ function placeCardInZone(card, zoneId, { animate = true } = {}) {
break;
}
}
updateTopZoneLayout();
updateSidebarVisibility();
updateZonesToggleUI(); // live update when zones change
}
function currentZoneForCard(card) {
@@ -234,7 +240,6 @@ function currentZoneForCard(card) {
const pid = card.parentNode.id || '';
if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER;
if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid;
// If card is temporarily in modal (header), treat as header
if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER;
return pid || null;
}
@@ -248,44 +253,6 @@ function saveCurrentLayout() {
writeLayout(layout);
}
function applyUserLayoutOrDefault() {
const layout = readLayout();
const hasAny = Object.keys(layout).length > 0;
// If we have saved user layout, honor it
if (hasAny) {
getCards().forEach(card => {
const targetZone = layout[card.id];
if (!targetZone) return;
// On small screens: if saved zone is the sidebar, temporarily place in top cols
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(card, target, { animate: false });
} else {
placeCardInZone(card, targetZone, { animate: false });
}
});
updateTopZoneLayout();
updateSidebarVisibility();
return;
}
// No saved layout yet: apply defaults
if (!isSmallScreen()) {
// Wide: default both to sidebar (if not already)
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
} else {
// Small: deterministic mapping
getCards().forEach(c => {
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(c, zone, { animate: false });
});
}
updateTopZoneLayout();
updateSidebarVisibility();
saveCurrentLayout(); // initialize baseline so future moves persist
}
// -------------------- responsive stash --------------------
function stashSidebarCardsBeforeSmall() {
const sb = getSidebar();
@@ -339,21 +306,62 @@ function enforceResponsiveZones() {
__wasSmall = nowSmall;
updateTopZoneLayout();
updateSidebarVisibility();
updateZonesToggleUI(); // keep icon in sync when responsive flips
}
// -------------------- header dock visibility helpers --------------------
function showHeaderDockPersistent() {
const h = getHeaderDropArea();
if (h) {
h.style.display = 'inline-flex';
h.classList.add('dock-visible');
}
}
function hideHeaderDockPersistent() {
const h = getHeaderDropArea();
if (h) {
h.classList.remove('dock-visible');
if (h.children.length === 0) h.style.display = 'none';
}
}
// -------------------- zones toggle (collapse to header) --------------------
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
function applyCollapsedBodyClass() {
// helps grid/containers expand the file list area when sidebar is hidden
document.body.classList.toggle('sidebar-hidden', isZonesCollapsed());
const main = document.querySelector('.main-wrapper') || document.querySelector('#main') || document.querySelector('main');
if (main) {
main.style.contain = 'size';
void main.offsetHeight;
setTimeout(() => { main.style.removeProperty('contain'); }, 0);
}
}
function setZonesCollapsed(collapsed) {
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
if (collapsed) {
// Move ALL cards to header icons (transient). Do not overwrite saved layout.
// Move ALL cards to header icons (transient) regardless of where they were.
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
const sb = getSidebar();
if (sb) sb.style.display = 'none';
} else {
// Restore the saved user layout.
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
}
updateSidebarVisibility();
updateTopZoneLayout();
ensureZonesToggle();
updateZonesToggleUI();
applyCollapsedBodyClass();
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
}
function getHeaderHost() {
@@ -379,7 +387,6 @@ function ensureZonesToggle() {
position: 'absolute',
top: `${TOGGLE_TOP_PX}px`,
left: `${TOGGLE_LEFT_PX}px`,
zIndex: '10010',
width: '38px',
height: '38px',
borderRadius: '19px',
@@ -424,7 +431,7 @@ function updateZonesToggleUI() {
iconEl.style.transition = 'transform 0.2s ease';
iconEl.style.display = 'inline-flex';
iconEl.style.alignItems = 'center';
// fun rotate if both cards are in top zone
// rotate if both cards are in top zone (only when not collapsed)
const tz = getTopZone();
const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard');
iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)';
@@ -486,7 +493,31 @@ function updateTopZoneLayout() {
if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none';
}
// drag visual helpers
// --- sidebar placeholder while dragging (only when empty) ---
function ensureSidebarPlaceholder() {
const sb = getSidebar();
if (!sb) return;
if (hasSidebarCards()) return; // only when empty
let ph = sb.querySelector('.sb-dnd-placeholder');
if (!ph) {
ph = document.createElement('div');
ph.className = 'sb-dnd-placeholder';
Object.assign(ph.style, {
height: '340px',
width: '100%',
visibility: 'hidden'
});
sb.appendChild(ph);
}
}
function removeSidebarPlaceholder() {
const sb = getSidebar();
if (!sb) return;
const ph = sb.querySelector('.sb-dnd-placeholder');
if (ph) ph.remove();
}
// -------------------- DnD core --------------------
function addTopZoneHighlight() {
const top = getTopZone();
if (!top) return;
@@ -525,6 +556,7 @@ function cleanupTopZoneAfterDrop() {
if (ph) ph.remove();
top.classList.remove('highlight');
top.style.minHeight = '';
// ✅ fixed selector string here
const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0;
top.style.display = hasAny ? '' : 'none';
}
@@ -539,11 +571,10 @@ function hideHeaderDropZone() {
const h = getHeaderDropArea();
if (h) {
h.classList.remove('drag-active');
if (h.children.length === 0) h.style.display = 'none';
if (h.children.length === 0 && !isZonesCollapsed()) h.style.display = 'none';
}
}
// -------------------- DnD core --------------------
function makeCardDraggable(card) {
if (!card) return;
const header = card.querySelector('.card-header');
@@ -573,11 +604,9 @@ function makeCardDraggable(card) {
const sb = getSidebar();
if (sb) {
sb.classList.add('active');
sb.classList.add('highlight');
sb.classList.add('active', 'highlight');
if (!isZonesCollapsed()) sb.style.display = 'block';
sb.style.removeProperty('height');
sb.style.minWidth = '280px';
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
}
showHeaderDropZone();
@@ -597,9 +626,8 @@ function makeCardDraggable(card) {
top: initialTop + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
minWidth: rect.width + 'px',
flexShrink: '0',
zIndex: '10000'
zIndex: '10000',
pointerEvents: 'none'
});
}, 450);
});
@@ -623,8 +651,7 @@ function makeCardDraggable(card) {
const sb = getSidebar();
if (sb) {
sb.classList.remove('highlight');
sb.style.height = '';
sb.style.minWidth = '';
removeSidebarPlaceholder();
}
let dropped = null;
@@ -663,22 +690,20 @@ function makeCardDraggable(card) {
}
}
// If not dropped anywhere, return to original container
if (!dropped) {
// return to original container
const orig = $(card.dataset.originalContainerId);
if (orig) {
orig.appendChild(card);
card.style.removeProperty('width');
animateVerticalSlide(card);
// keep previous zone in layout (no change)
}
} else {
// Persist user layout on manual move (including header)
setLayoutFor(card.id, dropped);
}
// Clear inline drag styles
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width']
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width', 'pointer-events']
.forEach(prop => card.style.removeProperty(prop));
removeTopZoneHighlight();
@@ -686,15 +711,52 @@ function makeCardDraggable(card) {
cleanupTopZoneAfterDrop();
updateTopZoneLayout();
updateSidebarVisibility();
updateZonesToggleUI();
});
}
// -------------------- defaults + layout --------------------
function applyUserLayoutOrDefault() {
const layout = readLayout();
const hasAny = Object.keys(layout).length > 0;
if (hasAny) {
getCards().forEach(card => {
const targetZone = layout[card.id];
if (!targetZone) return;
// On small screens: if saved zone is the sidebar, temporarily place in top cols
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(card, target, { animate: false });
} else {
placeCardInZone(card, targetZone, { animate: false });
}
});
updateTopZoneLayout();
updateSidebarVisibility();
return;
}
// No saved layout yet: apply defaults
if (!isSmallScreen()) {
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
} else {
getCards().forEach(c => {
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(c, zone, { animate: false });
});
}
updateTopZoneLayout();
updateSidebarVisibility();
saveCurrentLayout(); // initialize baseline so future moves persist
}
// -------------------- public API --------------------
export function loadSidebarOrder() {
// Backward compat: act as "apply layout"
applyUserLayoutOrDefault();
ensureZonesToggle();
updateZonesToggleUI();
applyCollapsedBodyClass();
}
export function loadHeaderOrder() {
@@ -704,9 +766,9 @@ export function loadHeaderOrder() {
const layout = readLayout();
// If collapsed: all cards appear as header icons
if (isZonesCollapsed()) {
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
saveHeaderOrder();
return;
}
@@ -715,6 +777,7 @@ export function loadHeaderOrder() {
getCards().forEach(card => {
if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card);
});
if (header.children.length === 0) header.style.display = 'none';
saveHeaderOrder();
}
@@ -727,6 +790,7 @@ export function initDragAndDrop() {
// 2) Paint controls/UI
ensureZonesToggle();
updateZonesToggleUI();
applyCollapsedBodyClass();
// 3) Make cards draggable
getCards().forEach(makeCardDraggable);
@@ -735,9 +799,7 @@ export function initDragAndDrop() {
let raf = null;
const onResize = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
enforceResponsiveZones();
});
raf = requestAnimationFrame(() => enforceResponsiveZones());
};
window.addEventListener('resize', onResize);
enforceResponsiveZones();

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) {
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
showToast("no_files_selected");
return;
}
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
const count = window.filesToDelete.length;
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
}
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
export function openUploadModal() {
const modal = document.getElementById('uploadModal');
const body = document.getElementById('uploadModalBody');
const card = document.getElementById('uploadCard'); // <-- your existing card
window.openUploadModal = openUploadModal;
window.__pendingDropData = null;
if (!modal || !body || !card) {
console.warn('Upload modal or upload card not found');
return;
}
// Create a hidden sentinel so we can put the card back in place later
if (!_uploadCardSentinel) {
_uploadCardSentinel = document.createElement('div');
_uploadCardSentinel.id = 'uploadCardSentinel';
_uploadCardSentinel.style.display = 'none';
card.parentNode.insertBefore(_uploadCardSentinel, card);
}
// Move the actual card node into the modal (keeps all existing listeners)
body.appendChild(card);
// Show modal
modal.style.display = 'block';
// Focus the chooser for quick keyboard flow
setTimeout(() => {
const chooseBtn = document.getElementById('customChooseBtn');
if (chooseBtn) chooseBtn.focus();
}, 50);
}
export function closeUploadModal() {
const modal = document.getElementById('uploadModal');
const card = document.getElementById('uploadCard');
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
}
if (modal) modal.style.display = 'none';
}
document.addEventListener("DOMContentLoaded", function () {
const cancelDelete = document.getElementById("cancelDeleteFiles");
if (cancelDelete) {
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not delete files"));
}
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
@@ -300,6 +348,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
@@ -633,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else {
showToast("Error: " + (data.error || "Could not copy files"), 5000);
}
@@ -685,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files moved successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not move files"));
}
@@ -822,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) => {
@@ -846,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

View File

@@ -312,7 +312,13 @@ const translations = {
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress"
"reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -2,7 +2,7 @@
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) {
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
refreshFolderIcon(window.currentFolder);
})
.catch(err => {
console.error("Error restoring files:", err);

View File

@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
/* -----------------------------------------------------
@@ -588,7 +589,7 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
});
@@ -895,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) {

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.8.12';
window.APP_VERSION = 'v1.9.3';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 541 KiB

54
scripts/manual-sync.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# === Update FileRise to v1.9.1 (safe rsync) ===
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
set -Eeuo pipefail
VER="v1.9.1"
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
# 0) (optional) quick backup of critical bits
stamp="$(date +%F-%H%M)"
mkdir -p /root/backups
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
public/.htaccess config users uploads metadata || true
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
# 1) Fetch the release zip
rm -rf "$TMP"
mkdir -p "$TMP"
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
# 2) Unzip to a staging dir
unzip -q "$TMP/$ASSET" -d "$TMP"
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
# 3) Sync code into /var/www
# - keep public/.htaccess
# - keep data dirs and current config.php
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
--exclude='users/***' \
--exclude='metadata/***' \
--exclude='config/config.php' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) (optional) Composer autoload optimization if composer is available
if command -v composer >/dev/null 2>&1; then
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
composer install --no-dev --optimize-autoloader
fi
# 6) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."

File diff suppressed because it is too large Load Diff

View File

@@ -10,23 +10,38 @@ class ACL
private static $path = null;
private const BUCKETS = [
'owners','read','write','share','read_own',
'create','upload','edit','rename','copy','move','delete','extract',
'share_file','share_folder'
'owners',
'read',
'write',
'share',
'read_own',
'create',
'upload',
'edit',
'rename',
'copy',
'move',
'delete',
'extract',
'share_file',
'share_folder'
];
private static function path(): string {
private static function path(): string
{
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path;
}
public static function normalizeFolder(string $f): string {
public static function normalizeFolder(string $f): string
{
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root';
return $f;
}
public static function purgeUser(string $user): bool {
public static function purgeUser(string $user): bool
{
$user = (string)$user;
$acl = self::$cache ?? self::loadFresh();
$changed = false;
@@ -41,49 +56,107 @@ class ACL
return $changed ? self::save($acl) : true;
}
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
}
return false;
}
public static function migrateSubtree(string $source, string $target): array
{
// PHP <8 polyfill
if (!function_exists('str_starts_with')) {
function str_starts_with(string $h, string $n): bool
{
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
}
}
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed' => false, 'moved' => 0];
$file = self::path(); // e.g. META_DIR/folder_acl.json
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
if (!is_array($map)) $map = [];
$prefix = $src;
$needle = $src . '/';
$new = $map;
$changed = false;
$moved = 0;
foreach ($map as $key => $entry) {
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
if (!$isMatch) continue;
unset($new[$key]);
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
// keep only known buckets (defensive)
if (is_array($entry)) {
$clean = [];
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
$entry = $clean ?: $entry;
}
// overwrite any existing entry at destination path (safer than union)
$new[$newKey] = $entry;
$changed = true;
$moved++;
}
if ($changed) {
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
@chmod($file, 0664);
self::$cache = $new; // keep in-process cache fresh if you use it
}
return ['changed' => $changed, 'moved' => $moved];
}
return false;
}
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
}
}
$acl['folders'] = $rebased;
self::save($acl);
}
$acl['folders'] = $rebased;
self::save($acl);
}
private static function loadFresh(): array {
private static function loadFresh(): array
{
$path = self::path();
if (!is_file($path)) {
@mkdir(dirname($path), 0755, true);
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own'=> [],
'read_own' => [],
'create' => [],
'upload' => [],
'edit' => [],
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$healed = false;
foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; }
if (!is_array($rec)) {
$rec = [];
$healed = true;
}
foreach (self::BUCKETS as $k) {
$v = $rec[$k] ?? [];
if (!is_array($v)) { $v = []; $healed = true; }
if (!is_array($v)) {
$v = [];
$healed = true;
}
$v = array_values(array_unique(array_map('strval', $v)));
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
if (($rec[$k] ?? null) !== $v) {
$rec[$k] = $v;
$healed = true;
}
}
}
unset($rec);
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return $data;
}
private static function save(array $acl): bool {
private static function save(array $acl): bool
{
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl;
return $ok;
}
private static function listFor(string $folder, string $key): array {
private static function listFor(string $folder, string $key): array
{
$acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : [];
}
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) {
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
public static function isAdmin(array $perms = []): bool {
public static function isAdmin(array $perms = []): bool
{
if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
if (
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
) {
return true;
}
return false;
}
public static function hasGrant(string $user, string $folder, string $cap): bool {
public static function hasGrant(string $user, string $folder, string $cap): bool
{
$folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap;
$arr = self::listFor($folder, $capKey);
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return false;
}
public static function isOwner(string $user, array $perms, string $folder): bool {
public static function isOwner(string $user, array $perms, string $folder): bool
{
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners');
}
public static function canManage(string $user, array $perms, string $folder): bool {
public static function canManage(string $user, array $perms, string $folder): bool
{
return self::isOwner($user, $perms, $folder);
}
public static function canRead(string $user, array $perms, string $folder): bool {
public static function canRead(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read');
}
public static function canReadOwn(string $user, array $perms, string $folder): bool {
public static function canReadOwn(string $user, array $perms, string $folder): bool
{
if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own');
}
public static function canWrite(string $user, array $perms, string $folder): bool {
public static function canWrite(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write');
}
public static function canShare(string $user, array $perms, string $folder): bool {
public static function canShare(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// Legacy-only explicit (to avoid breaking existing callers)
public static function explicit(string $folder): array {
public static function explicit(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// New: full explicit including granular
public static function explicitAll(string $folder): array {
public static function explicitAll(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
];
}
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return self::save($acl);
}
public static function applyUserGrantsAtomic(string $user, array $grants): array {
public static function applyUserGrantsAtomic(string $user, array $grants): array
{
$user = (string)$user;
$path = self::path();
$fh = @fopen($path, 'c+');
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
if (!flock($fh, LOCK_EX)) {
fclose($fh);
throw new RuntimeException('Cannot lock ACL storage');
}
try {
$raw = stream_get_contents($fh);
if ($raw === false) $raw = '';
$acl = json_decode($raw, true);
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
$rec =& $acl['folders'][$ff];
$rec = &$acl['folders'][$ff];
foreach (self::BUCKETS as $k) {
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
if ($m) {
$v = true;
$w = true;
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
}
if ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = true;
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
if ($w) {
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
}
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
if ($dl) $rec['delete'][] = $user;
if ($ex) $rec['extract'][] = $user;
if ($sf) $rec['share_file'][] = $user;
if ($sfo)$rec['share_folder'][] = $user;
if ($sfo) $rec['share_folder'][] = $user;
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
// --- Granular write family -----------------------------------------------
// --- Granular write family -----------------------------------------------
public static function canCreate(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreate(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canUpload(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canUpload(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canMove(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMove(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canDelete(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canDelete(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
/** Sharing: files use share, folders require share + full-view. */
public static function canShareFile(string $user, array $perms, string $folder): bool {
public static function canShareFile(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
}
public static function canShareFolder(string $user, array $perms, string $folder): bool {
public static function canShareFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');

90
src/models/FolderMeta.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
// src/models/FolderMeta.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
class FolderMeta
{
private static function path(): string {
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
}
public static function normalizeFolder(string $folder): string {
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
return ($f === '' || $f === 'root') ? 'root' : $f;
}
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
public static function normalizeHex(?string $hex): ?string {
if ($hex === null || $hex === '') return null;
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
throw new \InvalidArgumentException('Invalid color hex');
}
if (strlen($hex) === 4) {
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
}
return strtoupper($hex);
}
/** Read full map from disk */
public static function getMap(): array {
$file = self::path();
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
return is_array($map) ? $map : [];
}
/** Write full map to disk (atomic-ish) */
private static function writeMap(array $map): void {
$file = self::path();
$dir = dirname($file);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$tmp = $file . '.tmp';
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
@rename($tmp, $file);
@chmod($file, 0664);
}
/** Set or clear a color for one folder */
public static function setColor(string $folder, ?string $hex): array {
$folder = self::normalizeFolder($folder);
$hex = self::normalizeHex($hex);
$map = self::getMap();
if ($hex === null) unset($map[$folder]);
else $map[$folder] = $hex;
self::writeMap($map);
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
}
/** Migrate color entries for a whole subtree (used by move/rename) */
public static function migrateSubtree(string $source, string $target): array {
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
$map = self::getMap();
if (!$map) return ['changed'=>false, 'moved'=>0];
$new = $map;
$moved = 0;
foreach ($map as $key => $hex) {
$isSelf = ($key === $src);
$isSub = str_starts_with($key.'/', $src.'/');
if (!$isSelf && !$isSub) continue;
unset($new[$key]);
$suffix = substr($key, strlen($src)); // '' or '/child/...'
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
$new[$newKey] = $hex;
$moved++;
}
if ($moved) self::writeMap($new);
return ['changed'=> (bool)$moved, 'moved'=> $moved];
}
}

View File

@@ -10,6 +10,45 @@ 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 cant read, report empty (no leaks)
if (!$user || !ACL::canRead($user, $perms, $folder)) {
return ['folders' => 0, 'files' => 0];
}
// Resolve paths under UPLOAD_DIR
$root = rtrim((string)UPLOAD_DIR, '/\\');
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
$realRoot = @realpath($root);
$realPath = @realpath($path);
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
return ['folders' => 0, 'files' => 0];
}
// Count quickly, skipping UI-internal dirs
$folders = 0; $files = 0;
try {
foreach (new DirectoryIterator($realPath) as $f) {
if ($f->isDot()) continue;
$name = $f->getFilename();
if ($name === 'trash' || $name === 'profile_pics') continue;
if ($f->isDir()) $folders++; else $files++;
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
}
} catch (\Throwable $e) {
// Stay quiet + safe
$folders = 0; $files = 0;
}
return ['folders' => $folders, 'files' => $files];
}
/** Load the folder → owner map. */
public static function getFolderOwners(): array
{