Compare commits

...

81 Commits

Author SHA1 Message Date
github-actions[bot]
3b62e27c7c chore(release): set APP_VERSION to v2.0.4 [skip ci] 2025-11-27 02:42:10 +00:00
Ryan
f967134631 release(v2.0.4): harden sessions and align Pro paths with USERS_DIR 2025-11-26 21:41:59 -05:00
Ryan
6b93d65d6a docs(readme): add Heise / iX press section 2025-11-26 18:36:05 -05:00
github-actions[bot]
1856325b1f chore(release): set APP_VERSION to v2.0.3 [skip ci] 2025-11-26 08:58:36 +00:00
Ryan
9e6da52691 release(v2.0.3): polish uploads, header dock, and panel fly animations 2025-11-26 03:58:25 -05:00
Ryan
959206c91c docs(readme): link install, nginx and FAQ wiki pages 2025-11-23 22:11:28 -05:00
Ryan
837deddec5 docs: add full feature wiki to README 2025-11-23 22:07:06 -05:00
Ryan
2810b97568 chore(demo): update manual sync script and lock TOTP for demo account
- Update scripts/manual-sync.sh to pull v2.0.2, backup extra demo/Pro dirs,
  and safely rsync core code without touching data, bundles, or site overrides
- After sync, automatically flip FR_DEMO_MODE to true in config/config.php
  so the droplet always runs in demo mode
- Block TOTP enable/disable/setup and recovery code generation for the
  demo account when FR_DEMO_MODE is enabled, returning 403 with clear
  JSON errors
2025-11-23 06:43:51 -05:00
github-actions[bot]
175c5f962f chore(release): set APP_VERSION to v2.0.2 [skip ci] 2025-11-23 10:58:51 +00:00
Ryan
827e65e367 release(v2.0.2): add config-driven demo mode and lock demo account changes 2025-11-23 05:58:39 -05:00
Ryan
fd8029a6bf docs: highlight Pro user groups and client portals in README 2025-11-23 04:54:35 -05:00
github-actions[bot]
de79395c3d chore(release): set APP_VERSION to v2.0.1 [skip ci] 2025-11-23 09:29:51 +00:00
Ryan
aa6f40bc24 release(v2.0.1): fix: harden portal + core login redirects for codeql 2025-11-23 04:29:41 -05:00
Ryan
abc105e087 chore(docs): readme image updated 2025-11-23 04:19:09 -05:00
github-actions[bot]
d3bcac4db0 chore(release): set APP_VERSION to v2.0.0 [skip ci] 2025-11-23 09:15:59 +00:00
Ryan
0b065111b0 release(v2.0.0): feat(pro): client portals + portal login flow 2025-11-23 04:15:49 -05:00
github-actions[bot]
3589a1c232 chore(release): set APP_VERSION to v1.9.14 [skip ci] 2025-11-21 07:12:29 +00:00
Ryan
1b4a93b060 release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish 2025-11-21 02:12:17 -05:00
github-actions[bot]
bf077b142b chore(release): set APP_VERSION to v1.9.13 [skip ci] 2025-11-20 11:44:39 +00:00
Ryan
f78e2f3f16 release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals 2025-11-20 06:44:27 -05:00
github-actions[bot]
08a84419f0 chore(release): set APP_VERSION to v1.9.12 [skip ci] 2025-11-19 07:48:18 +00:00
Ryan
49d3588322 release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL 2025-11-19 02:48:06 -05:00
github-actions[bot]
e1b20a9f1d chore(release): set APP_VERSION to v1.9.11 [skip ci] 2025-11-18 20:07:36 +00:00
Ryan
0ec8103fbf release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) 2025-11-18 15:07:27 -05:00
github-actions[bot]
3b1ebdd77f chore(release): set APP_VERSION to v1.9.10 [skip ci] 2025-11-18 07:22:03 +00:00
Ryan
3726e2423d release(v1.9.10): add Pro bundle installer and admin panel polish 2025-11-18 02:21:52 -05:00
github-actions[bot]
5613710411 chore(release): set APP_VERSION to v1.9.9 [skip ci] 2025-11-17 02:31:19 +00:00
Ryan
08f7ffccbc release(v1.9.9): fix(branding): sanitize custom logo URL 2025-11-16 21:31:08 -05:00
Ryan
ad1d41fad8 docs(readme): simplify core README and highlight Pro edition 2025-11-16 21:22:11 -05:00
github-actions[bot]
99662cd2f2 chore(release): set APP_VERSION to v1.9.8 [skip ci] 2025-11-17 02:11:15 +00:00
Ryan
060a548af4 release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks 2025-11-16 21:11:06 -05:00
Ryan
9880adb417 docs(readme): replace old video demo section with latest v1.9.7 screenshot 2025-11-14 20:29:37 -05:00
github-actions[bot]
a56641e81c chore(release): set APP_VERSION to v1.9.7 [skip ci] 2025-11-15 01:11:29 +00:00
Ryan
3b636f69d8 release(v1.9.7): harden client path guard and refine header/folder strip CSS 2025-11-14 20:11:19 -05:00
github-actions[bot]
930ed954ec chore(release): set APP_VERSION to v1.9.6 [skip ci] 2025-11-14 10:00:07 +00:00
Ryan
402f590163 release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67) 2025-11-14 04:59:58 -05:00
github-actions[bot]
ef47ad2b52 chore(release): set APP_VERSION to v1.9.5 [skip ci] 2025-11-13 10:32:48 +00:00
Ryan
8cdff954d5 release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths 2025-11-13 05:32:33 -05:00
github-actions[bot]
01cfa597b9 chore(release): set APP_VERSION to v1.9.4 [skip ci] 2025-11-13 10:06:34 +00:00
Ryan
f5e42a2e81 release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
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
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
Ryan
8f03cc7456 release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client 2025-11-08 13:53:11 -05:00
github-actions[bot]
c9a99506d7 chore(release): set APP_VERSION to v1.8.10 [skip ci] 2025-11-08 18:33:52 +00:00
Ryan
04ec0a0830 release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul 2025-11-08 13:33:38 -05:00
github-actions[bot]
429cd0314a chore(release): set APP_VERSION to v1.8.9 [skip ci] 2025-11-08 03:10:24 +00:00
Ryan
ba29cc4822 release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64) 2025-11-07 22:10:14 -05:00
github-actions[bot]
e2cd304158 chore(release): set APP_VERSION to v1.8.8 [skip ci] 2025-11-07 07:57:42 +00:00
Ryan
ca8788a694 release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60 2025-11-07 02:57:30 -05:00
Ryan
dc45fed886 chore(ci): increase release delay to 10m to avoid ref replication race 2025-11-05 00:19:56 -05:00
github-actions[bot]
a9fe342175 chore(release): set APP_VERSION to v1.8.7 [skip ci] 2025-11-05 05:02:42 +00:00
Ryan
7669f5a10b release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives 2025-11-05 00:02:32 -05:00
Ryan
34a4e06a23 chore(ci): add manual trigger + bot-derived version detection for releases 2025-11-04 23:09:31 -05:00
github-actions[bot]
d00faf5fe7 chore(release): set APP_VERSION to v1.8.6 [skip ci] 2025-11-05 03:57:04 +00:00
Ryan
ad8cbc601a release(v1.8.6): fix large ZIP downloads + safer extract; close #60 2025-11-04 22:56:53 -05:00
Ryan
40e000b5bc chore(ci): release uses correct commit for version.js + harden workflow_run 2025-11-04 22:22:24 -05:00
Ryan
eee25a4dc6 ci: revert but keep delay 2025-11-04 22:04:15 -05:00
github-actions[bot]
d66f4d93cb chore(release): set APP_VERSION to v1.8.5 [skip ci] 2025-11-05 02:17:05 +00:00
Ryan
f4f7f8ef38 release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing needs: delay, final test 2025-11-04 21:16:55 -05:00
github-actions[bot]
0ccba45c40 chore(release): set APP_VERSION to v1.8.4 [skip ci] 2025-11-05 02:09:07 +00:00
103 changed files with 19014 additions and 7536 deletions

View File

@@ -2,65 +2,83 @@
name: Release on version.js update name: Release on version.js update
on: on:
push:
branches: ["master"]
paths:
- public/js/version.js
workflow_run: workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"] workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed] types: [completed]
branches: [master]
workflow_dispatch:
inputs:
ref:
description: "Ref (branch/sha) to build from (default: master)"
required: false
version:
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
required: false
permissions: permissions:
contents: write contents: write
jobs: jobs:
delay:
runs-on: ubuntu-latest
steps:
- name: Delay 3 minutes
run: sleep 180
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
concurrency: concurrency:
# Cancel older runs for the same branch/ref so only the latest proceeds group: release-${{ github.event_name }}-${{ github.run_id }}
group: release-${{ github.ref }} cancel-in-progress: false
cancel-in-progress: true
steps: steps:
- name: Checkout correct ref - name: Resolve source ref
id: pickref
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
REF_IN="${{ github.event.inputs.ref }}"
else
REF_IN="master"
fi
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
else
REF="${{ github.sha }}"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Using ref=$REF"
- name: Checkout chosen ref (full history + tags, no persisted token)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ steps.pickref.outputs.ref }}
fetch-depth: 0 fetch-depth: 0
# For workflow_run, use the triggering workflow's head_sha; else use the current SHA persist-credentials: false
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Ensure tags available - name: Determine version
run: |
git fetch --tags --force --prune --quiet
- name: Show recent tags (debug)
run: git tag --list "v*" --sort=-v:refname | head -n 20
- name: Read version from version.js
id: ver id: ver
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
echo "version.js at commit: $(git rev-parse --short HEAD)" if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
sed -n '1,80p' public/js/version.js || true VER="${{ github.event.inputs.version }}"
else
VER=$( if [[ ! -f public/js/version.js ]]; then
grep -Eo "APP_VERSION[^\\n]*['\"]v[0-9][^'\"]+['\"]" public/js/version.js \ echo "public/js/version.js not found; cannot auto-detect version." >&2
| sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/" \
| tail -n1
)
if [[ -z "${VER:-}" ]]; then
echo "Could not parse APP_VERSION from version.js" >&2
exit 1 exit 1
fi 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 "version=$VER" >> "$GITHUB_OUTPUT"
echo "Parsed version: $VER" echo "Detected version: $VER"
- name: Skip if tag already exists - name: Skip if tag already exists
id: tagcheck id: tagcheck
@@ -74,8 +92,7 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
fi fi
# Ensure the stamper is executable and has LF endings (helps if edited on Windows) - name: Prepare stamp script
- name: Prep stamper script
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
@@ -83,55 +100,89 @@ jobs:
sed -i 's/\r$//' scripts/stamp-assets.sh || true sed -i 's/\r$//' scripts/stamp-assets.sh || true
chmod +x scripts/stamp-assets.sh chmod +x scripts/stamp-assets.sh
- name: Build zip artifact (stamped) - name: Build stamped staging tree
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
VER="${{ steps.ver.outputs.version }}" # e.g. v1.8.2 VER="${{ steps.ver.outputs.version }}"
ZIP="FileRise-${VER}.zip"
# Clean staging copy (exclude dotfiles you dont want)
rm -rf staging rm -rf staging
rsync -a \ rsync -a \
--exclude '.git' --exclude '.github' \ --exclude '.git' --exclude '.github' \
--exclude 'resources' \ --exclude 'resources' \
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \ --exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
./ staging/ ./ staging/
# Stamp IN THE STAGING COPY (invoke via bash to avoid exec-bit issues)
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging" bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
- name: Verify placeholders are gone (staging) # --- PHP + Composer for vendor/ (production) ---
- name: Setup PHP
if: steps.tagcheck.outputs.exists == 'false'
id: php
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
coverage: none
ini-values: memory_limit=-1
- name: Cache Composer downloads
if: steps.tagcheck.outputs.exists == 'false'
uses: actions/cache@v4
with:
path: |
~/.composer/cache
~/.cache/composer
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
- name: Install PHP dependencies into staging
if: steps.tagcheck.outputs.exists == 'false'
env:
COMPOSER_MEMORY_LIMIT: -1
shell: bash
run: |
set -euo pipefail
pushd staging >/dev/null
if [[ -f composer.json ]]; then
composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress \
--optimize-autoloader \
--classmap-authoritative
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
else
echo "No composer.json in staging; skipping vendor install."
fi
popd >/dev/null
# --- end Composer ---
- name: Verify placeholders removed (skip vendor/)
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
ROOT="$(pwd)/staging" ROOT="$(pwd)/staging"
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \ if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--exclude-dir=vendor --exclude-dir=vendor-bin \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then --include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "---- DEBUG (show 10 hits with context) ----" echo "Unreplaced placeholders found in staging." >&2
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
| head -n 10 | while IFS=: read -r file line _; do
echo ">>> $file:$line"
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
echo "----------------------------------------"
done
exit 1 exit 1
fi fi
echo "OK: No unreplaced placeholders in staging." echo "OK: No unreplaced placeholders."
- name: Zip stamped staging - name: Zip artifact (includes vendor/)
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
VER="${{ steps.ver.outputs.version }}" VER="${{ steps.ver.outputs.version }}"
ZIP="FileRise-${VER}.zip" (cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
(cd staging && zip -r "../$ZIP" . >/dev/null)
- name: Compute SHA-256 checksum - name: Compute SHA-256
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
id: sum id: sum
shell: bash shell: bash
@@ -176,9 +227,9 @@ jobs:
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1) PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
fi fi
echo "prev=$PREV" >> "$GITHUB_OUTPUT" echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Previous tag or baseline: $PREV" echo "Previous tag/baseline: $PREV"
- name: Build release body (snippet + full changelog + checksum) - name: Build release body
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
@@ -189,7 +240,6 @@ jobs:
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}" COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
ZIP="FileRise-${VER}.zip" ZIP="FileRise-${VER}.zip"
SHA="${{ steps.sum.outputs.sha }}" SHA="${{ steps.sum.outputs.sha }}"
{ {
echo echo
if [[ -s CHANGELOG_SNIPPET.md ]]; then if [[ -s CHANGELOG_SNIPPET.md ]]; then
@@ -205,8 +255,6 @@ jobs:
echo "${SHA} ${ZIP}" echo "${SHA} ${ZIP}"
echo '```' echo '```'
} > RELEASE_BODY.md } > RELEASE_BODY.md
echo "Release body:"
sed -n '1,200p' RELEASE_BODY.md sed -n '1,200p' RELEASE_BODY.md
- name: Create GitHub Release - name: Create GitHub Release
@@ -214,8 +262,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.ver.outputs.version }} tag_name: ${{ steps.ver.outputs.version }}
# Point the tag at the same commit we checked out (handles workflow_run case) target_commitish: ${{ steps.pickref.outputs.ref }}
target_commitish: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
name: ${{ steps.ver.outputs.version }} name: ${{ steps.ver.outputs.version }}
body_path: RELEASE_BODY.md body_path: RELEASE_BODY.md
generate_release_notes: false generate_release_notes: false

View File

@@ -1,5 +1,781 @@
# Changelog # Changelog
## Changes 11/26/2025 (v2.0.4)
release(v2.0.4): harden sessions and align Pro paths with USERS_DIR
- Enable strict_types in config.php and AdminController
- Decouple PHP session lifetime from "remember me" window
- Regenerate session ID on persistent token auto-login
- Point Pro license / bundle paths at USERS_DIR instead of hardcoded /users
- Tweak folder management card drag offset for better alignment
---
## Changes 11/26/2025 (v2.0.3)
release(v2.0.3): polish uploads, header dock, and panel fly animations
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
so only one OS file-picker dialog can open at a time.
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
- Slightly tighten file summary margin in the file list header for better alignment with actions.
---
## Changes 11/23/2025 (v2.0.2)
release(v2.0.2): add config-driven demo mode and lock demo account changes
- Wire FR_DEMO_MODE through AdminModel/siteConfig and admin getConfig (demoMode flag)
- Drive demo detection in JS from __FR_SITE_CFG__.demoMode instead of hostname
- Show consistent login tip + toasts for demo using shared __FR_DEMO__ flag
- Block password changes for the demo user and profile picture uploads when in demo mode
- Keep normal user dropdown/admin UI visible even on the demo, while still protecting the demo account
---
## Changes 11/23/2025 (v2.0.0)
### FileRise Core v2.0.0 & FileRise Pro v1.1.0
```text
release(v2.0.0): feat(pro): client portals + portal login flow
release(v2.0.1): fix: harden portal + core login redirects for codeql
```
### Core v2.0.0
- **Portal plumbing in core**
- New public pages: `portal.html` and `portal-login.html` for client-facing views.
- New portal controller + API endpoints that read portal definitions from the Pro bundle, enforce expiry, and expose safe public metadata.
- Login flow now respects a `?redirect=` parameter so portals can bounce through login cleanly and land back on the right slug.
- **Admin UX + styling**
- Admin panel CSS pulled into a dedicated `adminPanelStyles.js` helper instead of inline styles.
- User Groups and Client Portals modals use the new shared styling and dark-mode tweaks so they match the rest of the UI.
- **Breadcrumb root fix**
- Breadcrumbs now always show **root** explicitly and behave correctly when youre at top level vs nested folders.
- **Routing**
- Apache rewrite added for pretty portal URLs:
`https://host/portal/<slug>``portal.html?slug=<slug>` without affecting other routes.
### Pro v1.1.0 Client Portals
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
- Create multiple portals, each with:
- Slug + display name
- Target folder
- Optional client email
- Upload-only / allow-download flags
- Per-portal expiry date
- Portal-level copy and branding:
- Optional title + instructions
- Accent color used throughout the portal UI
- Footer text at bottom of the portal page
- **Optional intake form before uploads**
- Enable a form per portal with fields: name, email, reference, notes.
- Per-field “default value” and “required” toggles.
- Form must be completed before uploads when enabled.
- **Submissions log**
- Each portal keeps a submissions list showing:
- Date/time, folder, submitting user, IP address
- The intake form values (name, email, reference, notes).
- **Client-facing experience**
- New portal UI with:
- Branded header (title + accent color)
- Optional intake form
- Drag-and-drop upload dropzone
- If downloads are enabled, a clean list/grid of files already in that portals folder with download buttons.
- **Portal login page**
- Minimal login screen that pulls title/accent/footer from portal metadata.
- After successful login, user is redirected back to the original portal URL.
---
## Changes 11/21/2025 (v1.9.14)
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
- Add ACL-aware folder stats and byte counts in FolderModel::countVisible()
- Show subfolders inline as rows above files in table view (Explorer-style)
- Page folders + files together and wire folder rows into existing DnD and context menu flows
- Add folder action buttons (move/rename/color/share) with capability checks from /api/folder/capabilities.php
- Cache folder capabilities and owners to avoid repeat calls per row
- Add user settings to toggle folder strip and inline folder rows (stored in localStorage)
- Default itemsPerPage to 50 and remember current page across renders
- Sync inline folder icon size to file row height and tweak vertical alignment for different row heights
- Update table headers + i18n keys to use Name / Size / Modified / Created / Owner labels
- Compact and consolidate light/dark theme CSS, search pill, pagination, and font-size controls
- Tighten file action button hit areas and add specific styles for folder move/rename buttons
---
## Changes 11/20/2025 (v1.9.13)
release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals
- Added compact, unified light/dark theme for core surfaces (file list, upload, folder manager, admin panel).
- Updated modals, dropdown menus, and editor header to use the same modern panel styling in both themes.
- Restyled search bar into a pill-shaped control with a dedicated icon chip and better hover states.
- Refined pagination (Prev/Next) and font size (A-/A+) buttons to be smaller, rounded, and more consistent.
- Normalized input fields so borders render cleanly and focus states are consistent across the app.
- Tweaked button shadows so primary actions (Create/Upload) pop without feeling heavy in light mode.
- Polished dark-mode colors for tables, rows, toasts, and meta text for a more “app-like” feel.
---
## Changes 11/19/2025 (v1.9.12)
release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL
- Add Pro user groups as a first-class ACL source:
- Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant().
- Treat group grants as additive only; they can never remove access.
- Introduce AclAdminController:
- Move getGrants/saveGrants logic into a dedicated controller.
- Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn).
- Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller.
- Implement Pro user group storage and APIs:
- Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}).
- Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf().
- Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks.
- Ship Pro-only endpoints from core instead of the bundle:
- Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE.
- Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image.
- Extend admin UI for user groups:
- Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips.
- Add “Edit folder access” for each group, reusing the existing folder grants grid.
- Overlay group grants when editing a users ACL:
- Show which caps are coming from groups, lock those checkboxes, and update tooltips.
- Show group membership badges in the user permissions list.
- Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only).
- Misc:
- Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1.
- Tweak modal border-radius styling to include the new userGroups and groupAcl modals.
---
## Changes 11/18/2025 (v1.9.11)
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
- media: add proper HTTP Range support to /api/file/download.php so HTML5
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
- media: avoid buffering the entire file in memory; stream from disk with
200/206 responses and Accept-Ranges for smoother playback and faster start times.
- media: keep video progress tracking, watched badges, and status chip behavior
unchanged but now compatible with the new streaming endpoint.
- ui: update the folder strip to be responsive:
- desktop: keep the existing "chip" layout with icon above name.
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
- ui: add simple lazy-loading for the folder strip so only the first batch of
folders is rendered initially, with a "Load more…" button to append chunks for
very large folder sets (stays friendly with 100k+ folders).
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
and keep mobile/desktop behavior clearly separated.
---
## Changes 11/18/2025 (v1.9.10)
release(v1.9.10): add Pro bundle installer and admin panel polish
- Add FileRise Pro section in admin panel with license management and bundle upload
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
- Update Pro badge/version UI with “update available” hint and link to filerise.net
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
---
## Changes 11/16/2025 (v1.9.9)
release(v1.9.9): fix(branding): sanitize custom logo URL preview
- Sanitize branding.customLogoUrl on the server before writing siteConfig.json
- Allow only http/https or site-relative paths; strip invalid/sneaky values
- Update adminPanel.js live logo preview to set img src/alt safely
- Addresses CodeQL XSS warning while keeping Pro branding logo overrides working
---
## Changes 11/16/2025 (v1.9.8)
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
- Add Pro feature flags + bootstrap wiring
- Define FR_PRO_ACTIVE/FR_PRO_TYPE/FR_PRO_EMAIL/FR_PRO_VERSION/FR_PRO_LICENSE_FILE
in config.php and optionally require src/pro/bootstrap_pro.php.
- Expose a `pro` block from AdminController::getConfig() so the UI can show
license status, type, email, and bundle version without leaking the raw key.
- Implement license save endpoint
- Add AdminController::setLicense() and /api/admin/setLicense.php to accept a
FRP1 license string via JSON, validate basic shape, and persist it to
FR_PRO_LICENSE_FILE with strict 0600 permissions.
- Return structured JSON success/error responses for the admin UI.
- Extend admin config model with branding + safer validation
- Add `branding.customLogoUrl`, `branding.headerBgLight`, and
`branding.headerBgDark` fields to AdminModel defaults and updateConfig().
- Introduce AdminModel::sanitizeLogoUrl() to allow only site-relative /uploads
paths or http(s) URLs; reject absolute filesystem paths, data: URLs, and
javascript: URLs.
- Continue to validate ONLYOFFICE docsOrigin as http(s) only, keeping core
config hardening intact.
- New Pro-aware Admin Panel UI
- Rework User Management section to group:
- Add user / Remove user
- Folder Access (per-folder ACL)
- User Permissions (account-level flags)
- Add Pro-only actions with clear gating:
- “User groups” button (Pro)
- “Client upload portal” button with “Pro · Coming soon” pill
- Add “FileRise Pro” section:
- Show current Pro status (Free vs Active) + license metadata.
- Textarea for pasting license key, file upload helper, and “Save license”
action wired to /api/admin/setLicense.php.
- Optional “Copy current license” button when a license is present.
- Add “Sponsor / Donations” section with fixed GitHub Sponsors and Ko-fi URLs
and one-click copy/open buttons.
- Header branding controls (Pro)
- Add Header Logo + Header Colors controls under Header Settings, gated by
`config.pro.active`.
- Allow uploading a logo via /api/pro/uploadBrandLogo.php and auto-filling the
normalized /uploads path.
- Add live-preview helpers to update the header logo and header background
colors in the running UI after saving.
- Apply branding on app boot
- Update main.js to read branding config on load and apply:
- Custom header logo (or fallback to /assets/logo.svg).
- Light/dark header background colors via CSS variables.
- Keeps header consistent with saved branding across reloads and before
opening the admin panel.
- Styling + UX polish
- Add styles for new admin sections: collapsible headers, dark-mode aware
modal content, and refined folder access grid.
- Introduce .btn-pro-admin and .btn-pro-pill classes to render “Pro” and
“Pro · Coming soon” pills overlayed on buttons, matching the existing
header “Core/Pro” badge treatment.
- Minor spacing/typography tweaks in admin panel and ACL UI.
Note: Core code remains MIT-licensed; Pro functionality is enabled via optional
runtime hooks and separate closed-source bundle, without changing the core
license text.
---
## Changes 11/14/2025 (v1.9.7)
release(v1.9.7): harden client path guard and refine header/folder strip CSS
- Tighten isSafeFolderPath() to reject dot-prefixed/invalid segments (client-side defense-in-depth on folder paths).
- Rework header layout: consistent logo sizing, centered title, cleaner button alignment, and better small-screen stacking.
- Polish user dropdown and icon buttons: improved hover/focus states, dark-mode colors, and rounded menu corners.
- Update folder strip tiles: cap tile width, allow long folder names to wrap neatly, and fine-tune text/icon alignment.
- Tweak folder tree rows: better label wrapping, vertical alignment, and consistent SVG folder icon rendering.
- Small CSS cleanup and normalization (body, main wrapper, media modal/progress styles) without changing behavior.
---
## Changes 11/14/2025 (v1.9.6)
release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
- Resumable uploads
- Normalize resumable GET “test chunk” handling in `UploadModel` using `resumableChunkNumber` + `resumableIdentifier`, returning explicit `status: "found"|"not found"`.
- Skip CSRF checks for resumable GET tests in `UploadController`, but keep strict CSRF validation for real POST uploads with soft-fail `csrf_expired` responses.
- Refactor `UploadModel::handleUpload()` for chunked uploads: strict filename validation, safe folder normalization, reliable temp chunk directory creation, and robust merge with clear errors if any chunk is missing.
- Add `UploadModel::removeChunks()` + internal `rrmdir()` to safely clean up `resumable_…` temp folders via a dedicated controller endpoint.
- Frontend resumable UX & persistence
- Enable `testChunks: true` for Resumable.js and wire GET checks to the new backend status logic.
- Track in-progress resumable files per user in `localStorage` (identifier, filename, folder, size, lastPercent, updatedAt) and show a resumable hint banner inside the Upload card with a dismiss button that clears the hints for that folder.
- Clamp client-side progress to max `99%` until the server confirms success, so aborted tabs still show resumable state instead of “100% done”.
- Improve progress UI: show upload speed, spinner while finalizing, and ensure progress elements exist even for non-standard flows (e.g., submit without prior list build).
- On complete success, clear the progress UI, reset the file input, cancel Resumables internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
- Hiding resumable temp folders
- Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
- Folder tree BFS traversal (child discovery / recursion).
- `listChildren.php` results and child-cache hydration.
- The inline folder strip above the file list (also filtered in `fileListView.js`).
- Folder manager context menu upgrade
- Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
- Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
- Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
- File context menu & selection logic
- Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
- Rebuild `fileMenu.js` to:
- Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
- Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
- Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
- Tagging system robustness
- Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
- Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
- Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
- Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
- Visual polish & theming
- Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
- Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
- Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
- Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
- Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
---
## Changes 11/13/2025 (v1.9.5)
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
- Replace innerHTML-based row construction in folderManager.js with safe DOM APIs
(createElement, textContent, dataset). All user-derived strings now use
textContent; only locally-generated SVG remains via innerHTML.
- Add isSafeFolderPath() client-side guard; fail closed on suspicious paths
before rendering clickable nodes.
- “Load more” button rebuilt with proper a11y:
- aria-label, optional aria-controls to the UL
- aria-busy + disabled during fetch; restore state only if the node is still
present (Node.isConnected).
- Keep lazy tree + cursor pagination behavior intact; chevrons/icons continue to
hydrate from server hints (hasSubfolders/nonEmpty) once available.
- Addresses CodeQL XSS findings by removing unsafe HTML interpolation and
avoiding HTML interpretation of extracted text.
No breaking changes; security + UX polish on top of v1.9.4.
---
## Changes 11/13/2025 (v1.9.4)
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
**Big focus on folder management performance & UX for large libraries.**
feat(folder-tree):
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
- BFS-based initial selection: if user cant view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
- Root DnD honored only when viewable; structural locks disable dragging.
perf(core):
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
api/model:
- `FolderModel::listChildren(...)` now returns items shaped like:
`{ name, locked, hasSubfolders, nonEmpty? }`
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
ui/ux:
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
security:
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
fixes:
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
chore:
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
- Minor code cleanups and comments across controller/model and JS tree logic.
---
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
- UI / Icons
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
- Folder color & non-empty detection
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
- FileList strip
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
- Exports & helpers
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
- AppCore
- Update file upload DnD relay hook to `#fileList` (id rename).
- CSS tweaks
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
- CI/CD (release)
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
- Changelog highlights
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
- Real-time folder color propagation between views.
- Folder strip switched to SVG tiles with better layout + context actions.
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
---
## Changes 11/10/2025 (v1.9.2)
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
- New “Upload file(s)” action in Create menu:
- Adds `<li id="uploadOption">` to the dropdown.
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
- Drag & Drop from file list → Upload:
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
- On drop, waits until the 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
- auth (public/js/main.js)
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
- Minor cleanup (SW register catch spacing).
- drag & drop (public/js/dragAndDrop.js)
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
- Cleaner header icon modal plumbing; remove legacy/dead code.
- styles (public/css/styles.css)
- Header drop zone fills remaining space and right-aligns its icons.
UX:
- OIDC button reliably appears when form/basic are disabled.
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
---
## Changes 11/8/2025 (v1.8.11)
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
- Force PKCE via setCodeChallengeMethod('S256') so Authelias public-client policy is satisfied.
- Convert empty OIDC client secret to null to correctly signal a public client.
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
---
## Changes 11/8/2025 (v1.8.10)
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
UI/UX — Media modal
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
- Restore “X” behavior and make hover theme-aware (red pill + white X in light, red pill + black X in dark).
Video/Image controls
- Top-right action icons use theme-aware styles and align with the filename row.
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
Progress badges (list & modal)
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
Drag & drop
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
Editor & ONLYOFFICE
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
Assets & polish
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
---
## Changes 11/7/2025 (v1.8.9)
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
- adminPanel.js:
- Masked inputs without a saved value now start with data-replace="1".
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
---
## Changes 11/7/2025 (v1.8.8)
release(v1.8.8): background ZIP jobs w/ tokenized download + inmodal progress bar; robust finalize; janitor cleanup — closes #60
**Summary**
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multiGB ZIP failures caused by request timeouts or crossdevice renames, and provides a resilient inmodal progress experience. It also adds a 6hour janitor for temporary tokens/logs.
**Backend** changes:
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for oneshot downloads.
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
New **Worker**:
- New `src/cli/zip_worker.php` builds the archive in the background.
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the pertoken JSON.
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
New **API/Status Payload**:
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
**Frontend (UX)** changes:
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
- Shows determinate **098%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
- Modal closes just before download; UI resets for the next operation.
Added **CSS**:
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
**Why this closes #60**?
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
- Users get continuous, truthful feedback for large multiGB archives.
Additional **Notes**
- Download tokens are **oneshot** and are deleted after the GET completes.
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
---
## Changes 11/5/2025 (v1.8.7)
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
- FileController::downloadZip
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
- Close session locks, disable gzip/output buffering, set Content-Length when known
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
- Unlink the temp ZIP after successful send
- Preserves all auth/ACL/ownership checks
- FileModel::createZipArchive
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
---
## Changes 11/4/2025 (v1.8.6)
release(v1.8.6): fix large ZIP downloads + safer extract; close #60
- Zip creation
- Write archives to META_DIR/ziptmp (on large/writable disk) instead of system tmp.
- Auto-create ziptmp (0775) and verify writability.
- Free-space sanity check (~files total +5% +20MB); clearer error on low space.
- Normalize/validate folder segments; include only regular files.
- set_time_limit(0); use CREATE|OVERWRITE; improved error handling.
- Zip extraction
- New: stamp metadata for files in nested subfolders (per-folder metadata.json).
- Skip hidden “dot” paths (files/dirs with any segment starting with “.”) by default
via SKIP_DOTFILES_ON_EXTRACT=true; only extract allow-listed entries.
- Hardenings: zip-slip guard, reject symlinks (external_attributes), zip-bomb limits
(MAX_UNZIP_BYTES default 200GiB, MAX_UNZIP_FILES default 20k).
- Persist metadata for all touched folders; keep extractedFiles list for top-level names.
Ops note: ensure /var/www/metadata/ziptmp exists & is writable (or mount META_DIR to a large volume).
Closes #60.
---
## Changes 11/4/2025 (v1.8.5)
release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing `needs: delay`, final test
- No change release just testing
---
## Changes 11/4/2025 (v1.8.4) ## Changes 11/4/2025 (v1.8.4)
release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races

529
README.md
View File

@@ -10,483 +10,192 @@
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311) [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311) [![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting) **FileRise** is a modern, self-hosted web file manager / WebDAV server.
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
**Elevate your File Management** A modern, self-hosted web file manager. - 💾 **Self-hosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external DB required.
Upload, organize, and share files or folders through a sleek, responsive web interface. - 🔐 **Granular per-folder ACLs** View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control. - 🔄 **Fast drag-and-drop uploads** Chunked, resumable uploads with pause/resume and progress.
- 🌳 **Scales to huge trees** Tested with **100k+ folders** in the sidebar tree.
- 🧩 **ONLYOFFICE support (optional)** Edit DOCX/XLSX/PPTX using your own Document Server.
- 🌍 **WebDAV** Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
- 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
- 👥 **User groups & client portals (Pro)** Group-based ACLs and brandable client upload portals.
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing. Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
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. ![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
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. > 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully open-source (MIT).
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
**10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:**
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
--- ---
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features) ## Quick links
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically. - 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation. - [Features overview](https://github.com/error311/FileRise/wiki/Features)
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB. - [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
- 🔐 **Granular Access Control (ACL):** - 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
Each grant controls specific actions across the UI, API, and WebDAV:
| Permission | Description |
|-------------|-------------|
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
| **Upload** | Allows uploading new files without granting full write privileges. |
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
ACL enforcement is centralized and atomic across:
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
- **API Endpoints:** All file/folder operations validate server-side.
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
- Listings require **View** or **View (Own)**.
- Uploads require **Upload**.
- Overwrites require **Edit**.
- Deletes require **Delete**.
- Creating folders requires **Create** or **Manage**.
- All ACLs and ownership rules are enforced exactly as in the web UI.
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
--- ---
## Live Demo ## 1. What FileRise does
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net) FileRise turns a folder on your server into a **webbased file explorer** with:
**Demo credentials:** `demo` / `demo`
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). **The demo is read-only for security.** Explore the interface, switch themes, preview files, and see FileRise in action! - Folder tree + breadcrumbs for fast navigation
- Multifile/folder draganddrop uploads
- Move / copy / rename / delete / extract ZIP
- Public share links (optionally passwordprotected & expiring)
- Tagging and search by name, tag, uploader, and content
- Trash with restore/purge
- Inline previews (images, audio, video, PDF) and a builtin code editor
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
--- ---
## Installation & Setup ## 2. Install (Docker recommended)
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server. The easiest way to run FileRise is the official Docker image.
---
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `TIMEZONE` | `UTC` | PHP/app timezone. |
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
---
### 1) Running with Docker (Recommended)
#### Pull the image
```bash ```bash
docker pull error311/filerise-docker:latest docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
``` ```
#### Run a container Then visit:
```bash ```text
docker run -d \ http://your-server-ip:8080
--name filerise \
-p 8080:80 \
-e TIMEZONE="America/New_York" \
-e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
-e PUID="1000" \
-e PGID="1000" \
-e CHOWN_ON_START="true" \
-e SCAN_ON_START="true" \
-e SHARE_URL="" \
-v ~/filerise/uploads:/var/www/uploads \
-v ~/filerise/users:/var/www/users \
-v ~/filerise/metadata:/var/www/metadata \
error311/filerise-docker:latest
``` ```
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS. On first launch youll be guided through creating the **initial admin user**.
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`. **More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
**Notes** [nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`). See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
**Verify ownership mapping (optional)**
```bash
docker exec -it filerise id www-data
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
```
#### Using Docker Compose
Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml
services:
filerise:
image: error311/filerise-docker:latest
container_name: filerise
ports:
- "8080:80"
environment:
TIMEZONE: "UTC"
DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false"
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
# Ownership & indexing
PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100
CHOWN_ON_START: "true" # first run; set to "false" afterwards
SCAN_ON_START: "true" # index files added outside the UI at boot
# Sharing URL (optional): leave blank to auto-detect from host/scheme
SHARE_URL: ""
volumes:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
restart: unless-stopped
```
Access at `http://localhost:8080` (or your servers IP).
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
-`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
**First-time Setup**
On first launch, if no users exist, youll be prompted to create an **Admin account**. Then use **User Management** to add more users.
--- ---
### 2) Manual Installation (PHP/Apache) ## 3. Manual install (PHP web server)
If you prefer a traditional web server (LAMP stack or similar): Prefer baremetal or your own stack? FileRise is just PHP + a few extensions.
**Requirements** **Requirements**
- PHP **8.3+** - PHP **8.3+**
- Apache (mod_php) or another web server configured for PHP - Web server (Apache / Nginx / Caddy + PHPFPM)
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required. - PHP extensions: `json`, `curl`, `zip` (and usual defaults)
- No database required
**Download Files** **Steps**
1. Clone or download FileRise into your web root:
```bash ```bash
git clone https://github.com/error311/FileRise.git git clone https://github.com/error311/FileRise.git
``` ```
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine. 2. Create data directories and set permissions:
**Composer (if applicable)** ```bash
cd FileRise
mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # adjust for your web user
chmod -R 775 uploads users metadata
```
3. (Optional) Install PHP dependencies with Composer:
```bash ```bash
composer install composer install
``` ```
**Folders & Permissions** 4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
- Apache: allow `.htaccess` or copy its rules into your vhost.
- Nginx/Caddy: mirror the basic protections (no directory listing, block sensitive files).
```bash 5. Browse to your FileRise URL and follow the **admin setup** screen.
mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # use your web user
chmod -R 775 uploads users metadata
```
- `uploads/`: actual files For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
- `users/`: credentials & token storage
- `metadata/`: file metadata (tags, share links, etc.)
**Configuration**
Edit `config.php`:
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
**Share link base URL**
- Set **`SHARE_URL`** via web-server env vars (preferred),
**or** keep using `BASE_URL` in `config.php` as a fallback.
- If neither is set, FileRise auto-detects from the current host/scheme.
**Web server config**
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
Browse to your FileRise URL; youll be prompted to create the Admin user on first load.
--- ---
### 3) Admins ## 4. WebDAV & ONLYOFFICE (optional)
> **Admins in ACL UI** ### WebDAV
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
--- Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
## Unraid - **macOS Finder** Go → Connect to Server → `https://your-host/webdav.php/`
- **Windows File Explorer** Map Network Drive → `https://your-host/webdav.php/`
- **Linux (GVFS/Nautilus)** `dav://your-host/webdav.php/`
- Clients like **Cyberduck**, **WinSCP**, etc.
- Install from **Community Apps** → search **FileRise**. WebDAV operations honor the same ACLs as the web UI.
- Default **bridge**: access at `http://SERVER_IP:8080/`.
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
--- See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
## Upgrade
```bash
docker pull error311/filerise-docker:latest
docker stop filerise && docker rm filerise
# re-run with the same -v and -e flags you used originally
```
---
## Quick-start: Mount via WebDAV
Once FileRise is running, enable WebDAV in the admin panel.
```bash
# Linux (GVFS/GIO)
gio mount dav://demo@your-host/webdav.php/
# macOS (Finder → Go → Connect to Server…)
https://your-host/webdav.php/
```
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
### Windows (File Explorer)
- Open **File Explorer** → Right-click **This PC****Map network drive…**
- Choose a drive letter (e.g., `Z:`).
- In **Folder**, enter:
```text
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.
---
## Quick start: ONLYOFFICE (optional)
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
**What you need**
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
- A shared **JWT secret** used by FileRise and your Document Server.
**Setup (23 minutes)**
1. In FileRise go to **Admin → ONLYOFFICE** and:
- ✅ Enable ONLYOFFICE
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
- 🔑 Enter **JWT Secret** (click “Replace” to set)
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
3. Update your **Content-Security-Policy** to allow the DS origin.
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
**Apache**
```apache
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
```
**Nginx**
```nginx
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
```
**Notes**
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
---
## FAQ / Troubleshooting
- **ONLYOFFICE editor wont load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHPs `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
- **How to enable HTTPS?** FileRise doesnt terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
- **Changing Admin or resetting password:** Admin can change any users password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
For more Q&A or to ask for help, open a Discussion or Issue.
---
## Security posture
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.
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
---
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
Areas to help: translations, bug fixes, UI polish, integrations.
If you like FileRise, a ⭐ star on GitHub is much appreciated!
---
## 💖 Sponsor FileRise
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
---
## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues.
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
[![Star History Chart](https://api.star-history.com/svg?repos=error311/FileRise&type=Date)](https://star-history.com/#error311/FileRise&Date)
---
## Dependencies
### ONLYOFFICE integration ### ONLYOFFICE integration
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server. If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs viewonly).
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**. Configure it 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 - Enable ONLYOFFICE
- Set your Document Server origin (e.g. `https://docs.example.com`)
- Configure a shared JWT secret
- Copy the suggested ContentSecurityPolicy header into your reverse proxy
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. Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
### Client-Side Libraries
- **Google Fonts** [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) For code editing functionality.
- **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) For file uploads.
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) For sanitizing HTML.
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) For indexed, fuzzy searching.
--- ---
## Acknowledgments ## 5. Security & updates
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston. - FileRise is actively maintained and has published security advisories.
- See **SECURITY.md** and GitHub Security Advisories for details.
- To upgrade:
- **Docker:** `docker pull error311/filerise-docker:latest` and recreate the container with the same volumes.
- **Manual:** replace app files with the latest release (keep `uploads/`, `users/`, `metadata/`, and your config).
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
--- ---
## License & Credits ## 6. Community, support & contributing
MIT License see [LICENSE](LICENSE). - 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons). - 💬 **Unraid forum thread:** for Unraidspecific setup and tuning.
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0). - 🌍 **Reddit / selfhosting communities:** occasional release posts & feedback threads.
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions. Contributions are welcome — from bug fixes and docs to translations and UI polish.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
- ☕ [Kofi](https://ko-fi.com/error311)
---
## 7. License & thirdparty code
FileRise Core is released under the **MIT License** see [LICENSE](LICENSE).
It bundles a small set of wellknown client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
All thirdparty code remains under its original licenses.
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
## 8. Press
- [Heise / iX Magazin “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
- [Heise / iX Magazin “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)

View File

@@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1);
// config.php // config.php
// Define constants // Define constants
@@ -16,6 +17,7 @@ define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[.
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+'); define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u'); define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u'); define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
define('FR_DEMO_MODE', false);
date_default_timezone_set(TIMEZONE); date_default_timezone_set(TIMEZONE);
@@ -33,6 +35,10 @@ define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
define('ONLYOFFICE_DEBUG', true); define('ONLYOFFICE_DEBUG', true);
*/ */
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
}
// Encryption helpers // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
{ {
@@ -96,10 +102,15 @@ $secure = ($envSecure !== false)
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN) ? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); : (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Choose session lifetime based on "remember me" cookie
// PHP session lifetime (independent of "remember me")
// Keep this reasonably short; "remember me" uses its own token.
$defaultSession = 7200; // 2 hours $defaultSession = 7200; // 2 hours
$sessionLifetime = $defaultSession;
// "Remember me" window (how long the persistent token itself is valid)
// This is used in persistent_tokens.json, *not* for PHP session lifetime.
$persistentDays = 30 * 24 * 60 * 60; // 30 days $persistentDays = 30 * 24 * 60 * 60; // 30 days
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
/** /**
* Start session idempotently: * Start session idempotently:
@@ -150,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
if (!empty($tokens[$token])) { if (!empty($tokens[$token])) {
$data = $tokens[$token]; $data = $tokens[$token];
if ($data['expiry'] >= time()) { if ($data['expiry'] >= time()) {
// NEW: mitigate session fixation
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$_SESSION["authenticated"] = true; $_SESSION["authenticated"] = true;
$_SESSION["username"] = $data["username"]; $_SESSION["username"] = $data["username"];
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]); $_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
@@ -157,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
} else { } else {
// expired — clean up // expired — clean up
unset($tokens[$token]); unset($tokens[$token]);
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX); file_put_contents(
$tokFile,
encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey),
LOCK_EX
);
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true); setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
} }
} }
@@ -235,3 +255,58 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
// Final: env var wins, else fallback // Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare); define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
// ------------------------------------------------------------
// FileRise Pro bootstrap wiring
// ------------------------------------------------------------
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
if (!defined('FR_PRO_LICENSE')) {
$envLicense = getenv('FR_PRO_LICENSE');
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
}
// JSON license file used by AdminController::setLicense()
if (!defined('PRO_LICENSE_FILE')) {
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
}
// Optional plain-text license file (used as fallback in bootstrap)
if (!defined('FR_PRO_LICENSE_FILE')) {
$lf = getenv('FR_PRO_LICENSE_FILE');
if ($lf === false || $lf === '') {
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
}
define('FR_PRO_LICENSE_FILE', $lf);
}
// Where Pro code lives by default → inside users volume
$proDir = getenv('FR_PRO_BUNDLE_DIR');
if ($proDir === false || $proDir === '') {
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
}
$proDir = rtrim($proDir, "/\\");
if (!defined('FR_PRO_BUNDLE_DIR')) {
define('FR_PRO_BUNDLE_DIR', $proDir);
}
// Try to load Pro bootstrap if enabled + present
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
if (@is_file($proBootstrap)) {
require_once $proBootstrap;
}
// If bootstrap didnt define these, give safe defaults
if (!defined('FR_PRO_ACTIVE')) {
define('FR_PRO_ACTIVE', false);
}
if (!defined('FR_PRO_INFO')) {
define('FR_PRO_INFO', [
'valid' => false,
'error' => null,
'payload' => null,
]);
}
if (!defined('FR_PRO_BUNDLE_VERSION')) {
define('FR_PRO_BUNDLE_VERSION', null);
}

View File

@@ -1,12 +1,16 @@
# -------------------------------- # --------------------------------
# FileRise portable .htaccess # FileRise portable .htaccess
# -------------------------------- # --------------------------------
Options -Indexes Options -Indexes -Multiviews
DirectoryIndex index.html DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known # Block direct access to dotfiles like .env, .gitignore, etc.
<FilesMatch "^\.(?!well-known(?:/|$))"> <FilesMatch "^\..*">
Require all denied Require all denied
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
@@ -15,15 +19,29 @@ DirectoryIndex index.html
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
# Never redirect local/dev hosts # 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/ RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
RewriteRule - - [L] RewriteRule - - [L]
# HTTPS redirect (enable ONE of these, comment the other) # 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)
# - allow /api.php (ReDoc/spec page)
# - allow /webdav.php (SabreDAV front)
RewriteCond %{REQUEST_URI} !^/api/ [NC]
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
RewriteRule \.php$ - [F,L]
# 3) Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# 4) HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server # A) Direct TLS on this server
#RewriteCond %{HTTPS} !=on #RewriteCond %{HTTPS} !=on
@@ -35,7 +53,7 @@ RewriteRule - - [L]
#RewriteCond %{HTTPS} !=on #RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Mark versioned assets (?v=...) with env flag for caching rules below # 5) Mark versioned assets (?v=...) with env flag for caching rules below
RewriteCond %{QUERY_STRING} (^|&)v= [NC] RewriteCond %{QUERY_STRING} (^|&)v= [NC]
RewriteRule ^ - [E=IS_VER:1] RewriteRule ^ - [E=IS_VER:1]
</IfModule> </IfModule>
@@ -98,7 +116,6 @@ RewriteRule ^ - [E=IS_VER:1]
# ---------------- Compression ---------------- # ---------------- Compression ----------------
<IfModule mod_brotli.c> <IfModule mod_brotli.c>
# Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only)
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule> </IfModule>
<IfModule mod_deflate.c> <IfModule mod_deflate.c>

View File

@@ -3,83 +3,26 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php'; require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json'); header('Content-Type: application/json');
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
} }
$user = trim((string)($_GET['user'] ?? '')); $user = trim((string)($_GET['user'] ?? ''));
if ($user === '' || !preg_match(REGEX_USER, $user)) {
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
}
// Build the folder list (admin sees all)
$folders = [];
try { try {
$rows = FolderModel::getFolderList(); $ctrl = new AclAdminController();
if (is_array($rows)) { $grants = $ctrl->getUserGrants($user);
foreach ($rows as $r) { echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; } catch (InvalidArgumentException $e) {
if ($f !== '') $folders[$f] = true; http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
} }
}
} catch (Throwable $e) { /* ignore */ }
if (empty($folders)) {
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
}
}
}
$folderList = array_keys($folders);
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
$has = function(array $arr, string $u): bool {
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
return false;
};
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicitAll($f); // legacy + granular
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare, // legacy
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
];
}
}
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);

View File

@@ -3,12 +3,11 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php'; require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json'); header('Content-Type: application/json');
// ---- Auth + CSRF -----------------------------------------------------------
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); http_response_code(401);
echo json_encode(['error' => 'Unauthorized']); echo json_encode(['error' => 'Unauthorized']);
@@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
exit; exit;
} }
// ---- Helpers ---------------------------------------------------------------
function normalize_caps(array $row): array {
// booleanize known keys
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
// BUSINESS RULES:
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
return $out;
}
function sanitize_grants_map(array $grants): array {
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$out[$folder] = normalize_caps($caps);
}
return $out;
}
function valid_user(string $u): bool {
return ($u !== '' && preg_match(REGEX_USER, $u));
}
// ---- Read JSON body --------------------------------------------------------
$raw = file_get_contents('php://input'); $raw = file_get_contents('php://input');
$in = json_decode((string)$raw, true); $in = json_decode((string)$raw, true);
if (!is_array($in)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
// ---- Single user mode: { user, grants } ------------------------------------
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
$user = trim((string)$in['user']);
if (!valid_user($user)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid user']);
exit;
}
$grants = sanitize_grants_map($in['grants']);
try { try {
$res = ACL::applyUserGrantsAtomic($user, $grants); $ctrl = new AclAdminController();
$res = $ctrl->saveUserGrantsPayload($in ?? []);
echo json_encode($res, JSON_UNESCAPED_SLASHES); echo json_encode($res, JSON_UNESCAPED_SLASHES);
exit; } catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
exit;
} }
}
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
if (isset($in['changes']) && is_array($in['changes'])) {
$updated = [];
foreach ($in['changes'] as $chg) {
if (!is_array($chg)) continue;
$user = trim((string)($chg['user'] ?? ''));
$gr = $chg['grants'] ?? null;
if (!valid_user($user) || !is_array($gr)) continue;
try {
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
$updated[$user] = $res['updated'] ?? [];
} catch (Throwable $e) {
$updated[$user] = ['error' => $e->getMessage()];
}
}
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
exit;
}
// ---- Fallback --------------------------------------------------------------
http_response_code(400);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$controller = new AdminController();
$controller->installProBundle();

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$ctrl = new AdminController();
$ctrl->setLicense();

View File

@@ -0,0 +1,24 @@
<?php
// public/api/file/downloadZipFile.php
/**
* @OA\Get(
* path="/api/file/downloadZipFile.php",
* summary="Download a finished ZIP by token",
* description="Streams the zip once; token is one-shot.",
* operationId="downloadZipFile",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
* @OA\Response(response=200, description="ZIP stream"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->downloadZipFile();

View File

@@ -0,0 +1,23 @@
<?php
// public/api/file/zipStatus.php
/**
* @OA\Get(
* path="/api/file/zipStatus.php",
* summary="Check status of a background ZIP build",
* description="Returns status for the authenticated user's token.",
* operationId="zipStatus",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Response(response=200, description="Status payload"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->zipStatus();

View File

@@ -1,245 +1,18 @@
<?php <?php
// public/api/folder/capabilities.php
/**
* @OA\Get(
* path="/api/folder/capabilities.php",
* summary="Get effective capabilities for the current user in a folder",
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
* operationId="getFolderCapabilities",
* tags={"Folders"},
* security={{"cookieAuth": {}}},
*
* @OA\Parameter(
* name="folder",
* in="query",
* required=false,
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
* @OA\Schema(type="string"),
* example="projects/acme"
* ),
*
* @OA\Response(
* response=200,
* description="Capabilities computed successfully.",
* @OA\JsonContent(
* type="object",
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
* @OA\Property(property="user", type="string", example="alice"),
* @OA\Property(property="folder", type="string", example="projects/acme"),
* @OA\Property(property="isAdmin", type="boolean", example=false),
* @OA\Property(
* property="flags",
* type="object",
* required={"folderOnly","readOnly","disableUpload"},
* @OA\Property(property="folderOnly", type="boolean", example=false),
* @OA\Property(property="readOnly", type="boolean", example=false),
* @OA\Property(property="disableUpload", type="boolean", example=false)
* ),
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
* )
* ),
* @OA\Response(response=400, description="Invalid folder name."),
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
* )
*/
declare(strict_types=1); declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
header('Content-Type: application/json'); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$username = (string)($_SESSION['username'] ?? '');
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
@session_write_close();
// --- auth --- $folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$username = $_SESSION['username'] ?? ''; $folder = str_replace('\\', '/', trim($folder));
if ($username === '') { $folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// --- helpers --- echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (Throwable $e) {}
return [];
}
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
}
$folder = implode('/', $parts);
}
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
],
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

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,28 @@
<?php
// Fast ACL-aware peek for tree icons/chevrons
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
echo json_encode($res, JSON_UNESCAPED_SLASHES);

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

@@ -0,0 +1,32 @@
<?php
// public/api/pro/groups/list.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/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$groups = $ctrl->getProGroups();
echo json_encode([
'success' => true,
'groups' => $groups,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => 'Error loading groups: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/groups/save.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/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']);
return;
}
$groups = $body['groups'] ?? null;
if (!is_array($groups)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid groups format.']);
return;
}
$ctrl = new AdminController();
$ctrl->saveProGroups($groups);
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => 'Error saving groups: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,27 @@
<?php
// public/api/pro/portals/get.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/PortalController.php';
try {
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
// For v1: we do NOT require auth here; this is just metadata,
// real ACL/access control must still be enforced at upload/download endpoints.
$portal = PortalController::getPortalBySlug($slug);
echo json_encode([
'success' => true,
'portal' => $portal,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/portals/list.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/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$portals = $ctrl->getProPortals();
echo json_encode([
'success' => true,
'portals' => $portals,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,108 @@
<?php
// public/api/pro/portals/publicMeta.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
// --- Basic Pro checks ---
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'FileRise Pro is not active.',
]);
exit;
}
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
if ($slug === '') {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Missing portal slug.',
]);
exit;
}
// --- Locate portals.json written by saveProPortals() ---
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
if ($bundleDir === '' || !is_dir($bundleDir)) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Pro bundle directory not found.',
]);
exit;
}
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
if (!is_file($jsonPath)) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'No portals defined.',
]);
exit;
}
$raw = @file_get_contents($jsonPath);
if ($raw === false) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Could not read portals store.',
]);
exit;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Invalid portals store.',
]);
exit;
}
$portals = $data['portals'] ?? [];
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Portal not found.',
]);
exit;
}
$portal = $portals[$slug];
// Optional: handle expiry if youre using expiresAt as ISO date string
if (!empty($portal['expiresAt'])) {
$ts = strtotime((string)$portal['expiresAt']);
if ($ts !== false && $ts < time()) {
http_response_code(410); // Gone
echo json_encode([
'success' => false,
'error' => 'This portal has expired.',
]);
exit;
}
}
// Only expose the bits the login page needs (no folder, email, etc.)
$public = [
'slug' => $slug,
'label' => (string)($portal['label'] ?? ''),
'title' => (string)($portal['title'] ?? ''),
'introText' => (string)($portal['introText'] ?? ''),
'brandColor' => (string)($portal['brandColor'] ?? ''),
'footerText' => (string)($portal['footerText'] ?? ''),
];
echo json_encode([
'success' => true,
'portal' => $public,
]);

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/portals/save.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/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
$portals = $body['portals'] ?? null;
if (!is_array($portals)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
return;
}
$ctrl = new AdminController();
$ctrl->saveProPortals($portals);
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
try {
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
@session_start();
$username = (string)($_SESSION['username'] ?? '');
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if ($username === '' || !$isAdmin) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden',
]);
return;
}
// Snapshot done, release lock for concurrency
@session_write_close();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
if ($slug === '') {
throw new InvalidArgumentException('Missing slug.');
}
// Use your ProPortalSubmissions helper from the bundle
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
if (!is_file($proSubmissionsPath)) {
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
}
require_once $proSubmissionsPath;
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
$submissions = $store->listBySlug($slug, 200);
echo json_encode([
'success' => true,
'slug' => $slug,
'submissions' => $submissions,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Server error: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,91 @@
<?php
// public/api/pro/portals/submitForm.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/PortalController.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// For now, portal forms still require a logged-in user
AdminController::requireAuth();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
$slug = isset($body['slug']) ? trim((string)$body['slug']) : '';
if ($slug === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing portal slug']);
return;
}
$form = isset($body['form']) && is_array($body['form']) ? $body['form'] : [];
$name = trim((string)($form['name'] ?? ''));
$email = trim((string)($form['email'] ?? ''));
$reference = trim((string)($form['reference'] ?? ''));
$notes = trim((string)($form['notes'] ?? ''));
// Make sure portal exists and is not expired
$portal = PortalController::getPortalBySlug($slug);
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$subPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
if (!is_file($subPath)) {
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
}
require_once $subPath;
$submittedBy = (string)($_SESSION['username'] ?? '');
$payload = [
'slug' => $slug,
'portalLabel' => $portal['label'] ?? '',
'folder' => $portal['folder'] ?? '',
'form' => [
'name' => $name,
'email' => $email,
'reference' => $reference,
'notes' => $notes,
],
'submittedBy' => $submittedBy,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'createdAt' => gmdate('c'),
];
$store = new ProPortalSubmissions(FR_PRO_BUNDLE_DIR);
$ok = $store->store($slug, $payload);
if (!$ok) {
throw new RuntimeException('Failed to store portal submission.');
}
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,28 @@
<?php
// public/api/pro/uploadBrandLogo.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
header('Content-Type: application/json; charset=utf-8');
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'FileRise Pro is not active on this instance.'
]);
exit;
}
try {
$ctrl = new UserController();
$ctrl->uploadBrandLogo();
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/assets/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/assets/logo-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

BIN
public/assets/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/assets/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/assets/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
public/assets/logo-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/assets/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -3,13 +3,20 @@
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script> <script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<style id="pretheme-css"> <style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;} html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style> </style>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg"> <!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI."> <meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark"> <meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}"> <link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}"> <link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
@@ -245,6 +252,9 @@
</div> </div>
</div> </div>
</div> </div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder"> <button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i> <i class="material-icons">share</i>
@@ -267,14 +277,24 @@
</div> </div>
</div> </div>
<div id="folderHelpTooltip" class="folder-help-tooltip" <div id="folderHelpTooltip" class="folder-help-tooltip"
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);"> style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;"> <style>
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li> /* Dark mode polish */
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li> body.dark-mode #folderHelpTooltip {
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
subfolder.</li> }
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click #folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
the appropriate button.</li> #folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -345,6 +365,10 @@
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;"> <li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="create_folder">Create folder</span> <span data-i18n-key="create_folder">Create folder</span>
</li> </li>
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="upload">Upload file(s)</span>
</li>
</ul> </ul>
</div> </div>
<!-- Create File Modal --> <!-- Create File Modal -->
@@ -453,6 +477,26 @@
</form> </form>
</div> </div>
</div> </div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="removeUserModal" class="modal" style="display:none;"> <div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3> <h3 data-i18n-key="remove_user_title">Remove User</h3>
@@ -484,6 +528,19 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:900px;width:92vw;">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Upload</h3>
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">&times;</span>
</div>
<div class="modal-body">
<!-- we will MOVE #uploadCard into here while open -->
<div id="uploadModalBody"></div>
</div>
</div>
</div>
</div> </div>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
// Admin panel inline CSS moved out of adminPanel.js
// This file is imported for its side effects only.
(function () {
if (document.getElementById('adminPanelStyles')) return;
const style = document.createElement('style');
style.id = 'adminPanelStyles';
style.textContent = `
/* Modal sizing */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 50%;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
@media (max-width: 900px) {
#adminPanelModal .modal-content {
width: 100%;
max-width: 100%;
}
}
@media (max-width: 768px) {
#adminPanelModal .modal-content {
width: 100%;
max-width: 100%;
border-radius: 0;
height: 100%;
}
}
/* Modal header */
#adminPanelModal .modal-header {
border-bottom: 1px solid rgba(0,0,0,0.15);
padding: 0.75rem 1rem;
align-items: center;
}
#adminPanelModal .modal-title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
#adminPanelModal .modal-title .admin-title-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.12);
background: rgba(0,0,0,0.03);
}
/* Modal body layout */
#adminPanelModal .modal-body {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem 1rem;
align-items: flex-start;
}
@media (max-width: 768px) {
#adminPanelModal .modal-body {
flex-direction: column;
}
}
/* Sidebar nav */
#adminPanelSidebar {
width: 220px;
max-width: 220px;
padding-right: 0.75rem;
border-right: 1px solid rgba(0,0,0,0.08);
}
@media (max-width: 768px) {
#adminPanelSidebar {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid rgba(0,0,0,0.08);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
}
#adminPanelSidebar .nav {
flex-direction: column;
gap: 0.25rem;
}
#adminPanelSidebar .nav-link {
border-radius: 0.5rem;
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid transparent;
color: #333;
}
#adminPanelSidebar .nav-link .material-icons {
font-size: 1rem;
}
#adminPanelSidebar .nav-link.active {
background: rgba(0, 123, 255, 0.08);
border-color: rgba(0, 123, 255, 0.3);
color: #0056b3;
}
#adminPanelSidebar .nav-link:hover {
background: rgba(0,0,0,0.03);
}
/* Content area */
#adminPanelContent {
flex: 1;
min-width: 0;
}
.admin-section-title {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.35rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.admin-section-title .material-icons {
font-size: 1rem;
}
.admin-section-subtitle {
font-size: 0.8rem;
color: rgba(0,0,0,0.6);
margin-bottom: 0.75rem;
}
.admin-field-group {
margin-bottom: 0.9rem;
}
.admin-field-group label {
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.admin-field-group small {
font-size: 0.75rem;
color: rgba(0,0,0,0.6);
}
.admin-inline-actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: center;
margin-top: 0.25rem;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
background: rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.08);
}
.admin-badge .material-icons {
font-size: 0.9rem;
}
/* Tables */
.admin-table-sm {
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.admin-table-sm th,
.admin-table-sm td {
padding: 0.35rem 0.4rem !important;
vertical-align: middle;
}
/* Switch alignment */
.form-check.form-switch .form-check-input {
cursor: pointer;
}
/* Pro license textarea */
#proLicenseInput {
font-family: var(--filr-font-mono, monospace);
font-size: 0.75rem;
min-height: 80px;
resize: vertical;
}
/* Pro info alert */
#proLicenseStatus {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
margin-bottom: 0.4rem;
}
/* Client portals */
#clientPortalsBody .portal-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
#clientPortalsBody .portal-row:last-child {
border-bottom: none;
}
#clientPortalsBody .portal-meta {
font-size: 0.75rem;
color: rgba(0,0,0,0.7);
}
#clientPortalsBody .portal-actions {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Submissions list */
#clientPortalsBody .portal-submissions {
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px dashed rgba(0,0,0,0.08);
}
#clientPortalsBody .portal-submissions-title {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.1rem;
opacity: 0.8;
}
#clientPortalsBody .portal-submissions-empty {
font-size: 0.75rem;
font-style: italic;
opacity: 0.6;
}
#clientPortalsBody .portal-submissions-item {
font-size: 0.75rem;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
#clientPortalsBody .portal-submissions-item:last-child {
border-bottom: none;
}
#clientPortalsBody .portal-submissions-meta {
opacity: 0.75;
font-size: 0.75rem;
}
/* Dark mode overrides */
.dark-mode #adminPanelModal .modal-content {
background: #121212 !important;
color: #f5f5f5 !important;
border-color: rgba(255,255,255,0.15) !important;
}
.dark-mode #adminPanelModal .modal-header {
border-bottom-color: rgba(255,255,255,0.15);
}
.dark-mode #adminPanelSidebar {
border-right-color: rgba(255,255,255,0.12);
}
.dark-mode #adminPanelSidebar .nav-link {
color: #f5f5f5;
}
.dark-mode #adminPanelSidebar .nav-link:hover {
background: rgba(255,255,255,0.04);
}
.dark-mode #adminPanelSidebar .nav-link.active {
background: rgba(13,110,253,0.3);
border-color: rgba(13,110,253,0.7);
color: #fff;
}
.dark-mode .admin-section-subtitle {
color: rgba(255,255,255,0.6);
}
.dark-mode .admin-field-group small {
color: rgba(255,255,255,0.6);
}
.dark-mode .admin-badge {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.12);
}
.dark-mode .admin-table-sm tbody tr:hover td {
background: rgba(255,255,255,0.02);
}
.dark-mode #clientPortalsBody .portal-row {
border-bottom-color: rgba(255,255,255,0.08);
}
.dark-mode #clientPortalsBody .portal-meta {
color: rgba(255,255,255,0.7);
}
.dark-mode #clientPortalsBody .portal-submissions {
border-top-color: rgba(255,255,255,0.12);
}
.dark-mode #clientPortalsBody .portal-submissions-empty {
color: rgba(255,255,255,0.5);
}
`;
document.head.appendChild(style);
})();

View File

@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}'; import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}'; import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}'; import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js?v={{APP_QVER}}'; import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
import { initUpload } from './upload.js?v={{APP_QVER}}'; import { initUpload } from './upload.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}'; import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
window.__pendingDropData = null;
function waitFor(selector, timeout = 1200) {
return new Promise(resolve => {
const t0 = performance.now();
(function tick() {
const el = document.querySelector(selector);
if (el) return resolve(el);
if (performance.now() - t0 >= timeout) return resolve(null);
requestAnimationFrame(tick);
})();
});
}
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse // Keep a bound handle to the native fetch so wrappers elsewhere never recurse
const _nativeFetch = window.fetch.bind(window); const _nativeFetch = window.fetch.bind(window);
@@ -76,7 +90,8 @@ export function initializeApp() {
window.currentFolder = last ? last : "root"; window.currentFolder = last ? last : "root";
const stored = localStorage.getItem('showFoldersInList'); const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true'; // default: false (unchecked)
window.showFoldersInList = stored === 'true';
// Load public site config early (safe subset) // Load public site config early (safe subset)
loadAdminConfigFunc(); loadAdminConfigFunc();
@@ -84,27 +99,56 @@ export function initializeApp() {
// Enable tag search UI; initial file list load is controlled elsewhere // Enable tag search UI; initial file list load is controlled elsewhere
initTagSearch(); initTagSearch();
/*
// Hook DnD relay from fileList area into upload area // Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileListContainer'); const fileListArea = document.getElementById('fileList');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) { if (fileListArea) {
let hoverTimer = null;
fileListArea.addEventListener('dragover', e => { fileListArea.addEventListener('dragover', e => {
e.preventDefault(); e.preventDefault();
fileListArea.classList.add('drop-hover'); fileListArea.classList.add('drop-hover');
// (optional) auto-open after brief hover so users see the drop target
if (!hoverTimer) {
hoverTimer = setTimeout(() => {
if (typeof window.openUploadModal === 'function') window.openUploadModal();
}, 400);
}
}); });
fileListArea.addEventListener('dragleave', () => { fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover'); fileListArea.classList.remove('drop-hover');
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
}); });
fileListArea.addEventListener('drop', e => {
fileListArea.addEventListener('drop', async e => {
e.preventDefault(); e.preventDefault();
fileListArea.classList.remove('drop-hover'); fileListArea.classList.remove('drop-hover');
uploadArea.dispatchEvent(new DragEvent('drop', { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
dataTransfer: e.dataTransfer,
bubbles: true, // 1) open the same modal that the Create menu uses
cancelable: true openUploadModal();
})); // 2) wait until the upload area exists *in the modal*, then relay the drop
}); // Prefer a scoped selector first to avoid duplicate IDs.
const uploadArea =
(await waitFor('#uploadModal #uploadDropArea')) ||
(await waitFor('#uploadDropArea'));
if (!uploadArea) return;
try {
// Many browsers make dataTransfer read-only; we try the direct attach first
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
uploadArea.dispatchEvent(relay);
} catch {
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
window.__pendingDropData = e.dataTransfer || null;
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
} }
});
}*/
// App subsystems // App subsystems
initDragAndDrop(); initDragAndDrop();

View File

@@ -34,17 +34,18 @@ window.currentOIDCConfig = currentOIDCConfig;
(function installToastFilter() { (function installToastFilter() {
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) { window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
const isDemoMode = !!window.__FR_DEMO__;
// Suppress the nag while doing TOTP step-up // Suppress the nag while doing TOTP step-up
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' || if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) { /please log in/i.test(String(msgKeyOrText)))) {
return null; // suppress return null; // suppress
} }
// Demo host // Demo mode: swap login prompt for demo creds
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' || if (isDemoMode &&
(msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) { /please log in/i.test(String(msgKeyOrText)))) {
return "Demo site — use:\nUsername: demo\nPassword: demo"; return "Demo site — use:\nUsername: demo\nPassword: demo";
} }
@@ -81,14 +82,16 @@ window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_requi
// override showToast to suppress the "Please log in to continue." toast during TOTP // override showToast to suppress the "Please log in to continue." toast during TOTP
function showToast(msgKeyOrText, type) { function showToast(msgKeyOrText, type) {
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net"; const isDemoMode = !!window.__FR_DEMO__;
// If it's the pre-login prompt and we're on the demo site, show demo creds instead. // For the pre-login prompt in demo mode, show demo creds instead
if (isDemoHost) { if (isDemoMode &&
(msgKeyOrText === "please_log_in_to_continue" ||
/please log in/i.test(String(msgKeyOrText)))) {
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000); return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
} }
// Dont nag during pending TOTP, as you already had // Dont nag during pending TOTP
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") { if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
return; return;
} }
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
let msg = msgKeyOrText; let msg = msgKeyOrText;
try { try {
const translated = t(msgKeyOrText); const translated = t(msgKeyOrText);
// If t() changed it or it's a key-like string, use the translation
if (typeof translated === "string" && translated !== msgKeyOrText) { if (typeof translated === "string" && translated !== msgKeyOrText) {
msg = translated; msg = translated;
} }
} catch { /* if t() isnt available here, just use the original */ } } catch { }
return originalShowToast(msg); return originalShowToast(msg);
} }
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
if (r) r.style.display = "none"; if (r) r.style.display = "none";
} }
// b) admin panel button only on demo.filerise.net
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
let a = document.getElementById("adminPanelBtn");
if (!a) {
a = document.createElement("button");
a.id = "adminPanelBtn";
a.classList.add("btn", "btn-info");
a.setAttribute("data-i18n-title", "admin_panel");
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(a, document.getElementById("restoreFilesBtn"));
a.addEventListener("click", openAdminPanel);
}
a.style.display = "block";
} else {
const a = document.getElementById("adminPanelBtn");
if (a) a.style.display = "none";
}
// c) user dropdown on non-demo // c) user dropdown on non-demo
if (window.location.hostname !== "demo.filerise.net") { {
let dd = document.getElementById("userDropdown"); let dd = document.getElementById("userDropdown");
// choose icon *or* img // choose icon *or* img
@@ -866,6 +850,10 @@ function initAuth() {
}); });
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal); document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
document.getElementById("changePasswordBtn").addEventListener("click", function () { document.getElementById("changePasswordBtn").addEventListener("click", function () {
if (window.__FR_DEMO__) {
showToast("Password changes are disabled on the public demo.");
return;
}
document.getElementById("changePasswordModal").style.display = "block"; document.getElementById("changePasswordModal").style.display = "block";
document.getElementById("oldPassword").focus(); document.getElementById("oldPassword").focus();
}); });
@@ -873,6 +861,10 @@ function initAuth() {
document.getElementById("changePasswordModal").style.display = "none"; document.getElementById("changePasswordModal").style.display = "none";
}); });
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () { document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
if (window.__FR_DEMO__) {
showToast("Password changes are disabled on the public demo.");
return;
}
const oldPassword = document.getElementById("oldPassword").value.trim(); const oldPassword = document.getElementById("oldPassword").value.trim();
const newPassword = document.getElementById("newPassword").value.trim(); const newPassword = document.getElementById("newPassword").value.trim();
const confirmPassword = document.getElementById("confirmPassword").value.trim(); const confirmPassword = document.getElementById("confirmPassword").value.trim();

View File

@@ -195,7 +195,6 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'}; color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px; padding: 20px;
max-width: 600px; width:90%; max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 500px; overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'}; border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box; box-sizing: border-box;
@@ -352,30 +351,73 @@ export async function openUserPanel() {
langFs.appendChild(langSel); langFs.appendChild(langSel);
content.appendChild(langFs); content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” --- // --- Display fieldset: strip + inline folder rows ---
const dispFs = document.createElement('fieldset'); const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px'; dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend'); const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display'); dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend); dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer'; // 1) Show folder strip above list
const dispCb = document.createElement('input'); const stripLabel = document.createElement('label');
dispCb.type = 'checkbox'; stripLabel.style.cursor = 'pointer';
dispCb.id = 'showFoldersInList'; stripLabel.style.display = 'block';
dispCb.style.verticalAlign = 'middle'; stripLabel.style.marginBottom = '4px';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true'; const stripCb = document.createElement('input');
dispLabel.appendChild(dispCb); stripCb.type = 'checkbox';
dispLabel.append(` ${t('show_folders_above_files')}`); stripCb.id = 'showFoldersInList';
dispFs.appendChild(dispLabel); stripCb.style.verticalAlign = 'middle';
{
const storedStrip = localStorage.getItem('showFoldersInList');
// default: unchecked
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
stripLabel.appendChild(stripCb);
stripLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(stripLabel);
// 2) Show inline folder rows above files in table view
const inlineLabel = document.createElement('label');
inlineLabel.style.cursor = 'pointer';
inlineLabel.style.display = 'block';
const inlineCb = document.createElement('input');
inlineCb.type = 'checkbox';
inlineCb.id = 'showInlineFolders';
inlineCb.style.verticalAlign = 'middle';
{
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
inlineLabel.appendChild(inlineCb);
// youll want a string like this in i18n:
// "show_inline_folders": "Show folders inline (above files)"
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
dispFs.appendChild(inlineLabel);
content.appendChild(dispFs); content.appendChild(dispFs);
dispCb.addEventListener('change', () => { // Handlers: toggle + refresh list
window.showFoldersInList = dispCb.checked; stripCb.addEventListener('change', () => {
localStorage.setItem('showFoldersInList', dispCb.checked); window.showFoldersInList = stripCb.checked;
// reload the entire file list (and strip) in one go: localStorage.setItem('showFoldersInList', stripCb.checked);
loadFileList(window.currentFolder); if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
}); });
// wire up imageinput change // wire up imageinput change
@@ -426,6 +468,18 @@ export async function openUserPanel() {
modal.querySelector('#userTOTPEnabled').checked = totp_enabled; modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en'; modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`; modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
// sync display toggles from localStorage
const stripCb = modal.querySelector('#showFoldersInList');
const inlineCb = modal.querySelector('#showInlineFolders');
if (stripCb) {
const storedStrip = localStorage.getItem('showFoldersInList');
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
if (inlineCb) {
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
} }
// show // show

View File

@@ -156,15 +156,15 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
export function buildFileTableHeader(sortOrder) { export function buildFileTableHeader(sortOrder) {
return ` return `
<table class="table"> <table class="table filr-table table-hover table-striped">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th> <th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("file_size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th> <th>${t("actions")}</th>
</tr> </tr>
</thead> </thead>
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr'); const row = checkbox.closest('tr');
if (!row) return; if (!row) return;
if (checkbox.checked) { if (checkbox.checked) {
row.classList.add('row-selected'); row.classList.add('row-selected', 'selected');
} else { } else {
row.classList.remove('row-selected'); row.classList.remove('row-selected', 'selected');
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}'; import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}'; import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) { export function handleDeleteSelected(e) {
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
showToast("no_files_selected"); showToast("no_files_selected");
return; return;
} }
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value); window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
const count = window.filesToDelete.length; const count = window.filesToDelete.length;
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count }); document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles"); attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
} }
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
export function openUploadModal() {
const modal = document.getElementById('uploadModal');
const body = document.getElementById('uploadModalBody');
const card = document.getElementById('uploadCard'); // <-- your existing card
window.openUploadModal = openUploadModal;
window.__pendingDropData = null;
if (!modal || !body || !card) {
console.warn('Upload modal or upload card not found');
return;
}
// Create a hidden sentinel so we can put the card back in place later
if (!_uploadCardSentinel) {
_uploadCardSentinel = document.createElement('div');
_uploadCardSentinel.id = 'uploadCardSentinel';
_uploadCardSentinel.style.display = 'none';
card.parentNode.insertBefore(_uploadCardSentinel, card);
}
// Move the actual card node into the modal (keeps all existing listeners)
body.appendChild(card);
// Show modal
modal.style.display = 'block';
// Focus the chooser for quick keyboard flow
setTimeout(() => {
const chooseBtn = document.getElementById('customChooseBtn');
if (chooseBtn) chooseBtn.focus();
}, 50);
}
export function closeUploadModal() {
const modal = document.getElementById('uploadModal');
const card = document.getElementById('uploadCard');
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
}
if (modal) modal.style.display = 'none';
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const cancelDelete = document.getElementById("cancelDeleteFiles"); const cancelDelete = document.getElementById("cancelDeleteFiles");
if (cancelDelete) { if (cancelDelete) {
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files deleted successfully!"); showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not delete files")); showToast("Error: " + (data.error || "Could not delete files"));
} }
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error); if (!js.success) throw new Error(js.error);
showToast(t('file_created')); showToast(t('file_created'));
loadFileList(folder); loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
} finally { } finally {
@@ -300,6 +348,7 @@ document.addEventListener("DOMContentLoaded", () => {
} }
showToast(t('file_created_successfully')); showToast(t('file_created_successfully'));
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
@@ -321,63 +370,187 @@ document.addEventListener("DOMContentLoaded", () => {
confirmZipBtn.addEventListener("click", async () => { confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename // a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim(); let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) { if (!zipName) { showToast("Please enter a name for the zip file."); return; }
showToast("Please enter a name for the zip file."); if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
return;
}
if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip";
}
// b) Hide the nameinput modal, show the spinner modal // b) Hide the nameinput modal, show the progress modal
zipNameModal.style.display = "none"; zipNameModal.style.display = "none";
progressModal.style.display = "block"; progressModal.style.display = "block";
// c) (Optional) update the “Preparing…” text if you gave it an ID // c) Title text (optional)
const titleEl = document.getElementById("downloadProgressTitle"); const titleEl = document.getElementById("downloadProgressTitle");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`; if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
try { // d) Queue the job
// d) POST and await the ZIP blob
const res = await fetch("/api/file/downloadZip.php", { const res = await fetch("/api/file/downloadZip.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json", body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || "root",
files: window.filesToDownload
})
}); });
if (!res.ok) { const jsr = await res.json().catch(() => ({}));
const txt = await res.text(); if (!res.ok || !jsr.ok) {
throw new Error(txt || `Status ${res.status}`); const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
throw new Error(msg);
}
const token = jsr.token;
const statusUrl = jsr.statusUrl;
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
// Ensure a progress UI exists in the modal
function ensureZipProgressUI() {
const modalEl = document.getElementById("downloadProgressModal");
if (!modalEl) {
// really shouldn't happen, but fall back to body
console.warn("downloadProgressModal not found; falling back to document.body");
}
// Prefer a dedicated content node inside the modal
let host =
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
(modalEl && modalEl.querySelector(".modal-body")) ||
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
(modalEl && modalEl.querySelector(".modal-content")) ||
(modalEl && modalEl.querySelector(".content")) ||
null;
// If no suitable container, create one inside the modal
if (!host) {
host = document.createElement("div");
host.id = "downloadProgressContent";
(modalEl || document.body).appendChild(host);
} }
const blob = await res.blob(); // Helper: ensure/move an element with given id into host
if (!blob || blob.size === 0) { function ensureInHost(id, tag, init) {
throw new Error("Received empty ZIP file."); let el = document.getElementById(id);
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
if (!el) {
el = document.createElement(tag);
el.id = id;
if (typeof init === "function") init(el);
host.appendChild(el);
}
return el;
} }
// e) Hand off to the browsers download manager // Title
const url = URL.createObjectURL(blob); const title = ensureInHost("downloadProgressTitle", "div", (el) => {
el.style.marginBottom = "8px";
el.textContent = "Preparing…";
});
// Progress bar (native <progress>)
const bar = (function () {
let el = document.getElementById("downloadProgressBar");
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
if (!el) {
el = document.createElement("progress");
el.id = "downloadProgressBar";
host.appendChild(el);
}
el.max = 100;
el.value = 0;
el.style.display = ""; // override any inline display:none
el.style.width = "100%";
el.style.height = "1.1em";
return el;
})();
// Text line
const text = ensureInHost("downloadProgressText", "div", (el) => {
el.style.marginTop = "8px";
el.style.fontSize = "0.9rem";
el.style.whiteSpace = "nowrap";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
});
// Optional spinner hider
const hideSpinner = () => {
const sp = document.getElementById("downloadSpinner");
if (sp) sp.style.display = "none";
};
return { bar, text, title, hideSpinner };
}
function humanBytes(n) {
if (!Number.isFinite(n) || n < 0) return "";
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
}
function mmss(sec) {
sec = Math.max(0, sec | 0);
const m = (sec / 60) | 0, s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
const ui = ensureZipProgressUI();
const t0 = Date.now();
// e) Poll until ready
while (true) {
await new Promise(r => setTimeout(r, 1200));
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
credentials: "include", cache: "no-store",
}).then(r => r.json());
if (s.error) throw new Error(s.error);
if (ui.title) ui.title.textContent = `Preparing ${zipName}`;
// --- RENDER PROGRESS ---
if (typeof s.pct === "number" && ui.bar && ui.text) {
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
ui.hideSpinner && ui.hideSpinner();
const filesDone = s.filesDone ?? 0;
const filesTotal = s.filesTotal ?? 0;
const bytesDone = s.bytesDone ?? 0;
const bytesTotal = s.bytesTotal ?? 0;
// Determinate 098% while enumerating
const pct = Math.max(0, Math.min(98, s.pct | 0));
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
ui.bar.value = pct;
ui.text.textContent =
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
} else {
// FINALIZING: keep progress at 100% and show timer + selected totals
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
ui.bar.value = 100; // lock at 100 during finalizing
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
ui.text.textContent = `Finalizing… ${mmss(since)}${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
}
} else if (ui.text) {
ui.text.textContent = "Still preparing…";
}
// --- /RENDER ---
if (s.ready) {
// Snap to 100 and close modal just before download
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
progressModal.style.display = "none";
await new Promise(r => setTimeout(r, 0));
break;
}
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
}
// f) Trigger download
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = downloadUrl;
a.download = zipName; a.download = zipName;
a.style.display = "none";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
URL.revokeObjectURL(url);
a.remove(); a.remove();
} catch (err) { // g) Reset for next time
console.error("Error downloading ZIP:", err); if (ui.bar) ui.bar.value = 0;
showToast("Error: " + err.message); if (ui.text) ui.text.textContent = "";
} finally { if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
// f) Always hide spinner modal
progressModal.style.display = "none";
}
}); });
} }
}); });
@@ -509,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files copied successfully!", 5000); showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not copy files"), 5000); showToast("Error: " + (data.error || "Could not copy files"), 5000);
} }
@@ -561,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files moved successfully!"); showToast("Selected files moved successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not move files")); showToast("Error: " + (data.error || "Could not move files"));
} }
@@ -698,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
const menu = document.getElementById('createMenu'); const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption'); const fileOpt = document.getElementById('createFileOption');
const folderOpt = document.getElementById('createFolderOption'); const folderOpt = document.getElementById('createFolderOption');
const uploadOpt = document.getElementById('uploadOption'); // NEW
// Toggle dropdown on click // Toggle dropdown on click
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -722,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', () => { document.addEventListener('click', () => {
menu.style.display = 'none'; menu.style.display = 'none';
}); });
if (uploadOpt) {
uploadOpt.addEventListener('click', () => {
if (menu) menu.style.display = 'none';
openUploadModal();
});
}
// Close buttons / backdrop
const upModal = document.getElementById('uploadModal');
const closeX = document.getElementById('closeUploadModal');
if (closeX) closeX.addEventListener('click', closeUploadModal);
// click outside content to close
if (upModal) {
upModal.addEventListener('click', (e) => {
if (e.target === upModal) closeUploadModal();
});
}
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
closeUploadModal();
}
});
}); });
window.renameFile = renameFile; window.renameFile = renameFile;

View File

@@ -2,124 +2,163 @@
import { showToast } from './domUtils.js?v={{APP_QVER}}'; import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
export function fileDragStartHandler(event) { /* ---------------- helpers ---------------- */
const row = event.currentTarget; function getRowEl(el) {
let fileNames = []; return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
if (selectedCheckboxes.length > 1) {
selectedCheckboxes.forEach(chk => {
const parentRow = chk.closest("tr");
if (parentRow) {
const cell = parentRow.querySelector("td:nth-child(2)");
if (cell) {
let rawName = cell.textContent.trim();
const tagContainer = cell.querySelector(".tag-badges");
if (tagContainer) {
const tagText = tagContainer.innerText.trim();
if (rawName.endsWith(tagText)) {
rawName = rawName.slice(0, -tagText.length).trim();
} }
function getNameFromAny(el) {
const row = getRowEl(el);
if (!row) return null;
// 1) canonical
const n = row.getAttribute('data-file-name');
if (n) return n;
// 2) filename-only span
const span = row.querySelector('.filename-text');
if (span) return span.textContent.trim();
return null;
} }
fileNames.push(rawName); function getSelectedFileNames() {
} const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
// de-dup just in case
return Array.from(new Set(names));
} }
function makeDragImage(labelText, iconName = 'insert_drive_file') {
const wrap = document.createElement('div');
Object.assign(wrap.style, {
display: 'inline-flex',
maxWidth: '420px',
padding: '6px 10px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '6px',
alignItems: 'center',
gap: '6px',
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
fontSize: '12px',
pointerEvents: 'none'
}); });
} else { const icon = document.createElement('span');
const fileNameCell = row.querySelector("td:nth-child(2)"); icon.className = 'material-icons';
if (fileNameCell) { icon.textContent = iconName;
let rawName = fileNameCell.textContent.trim(); const label = document.createElement('span');
const tagContainer = fileNameCell.querySelector(".tag-badges"); // trim long single-name labels
if (tagContainer) { const txt = String(labelText || '');
const tagText = tagContainer.innerText.trim(); label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
if (rawName.endsWith(tagText)) { wrap.appendChild(icon);
rawName = rawName.slice(0, -tagText.length).trim(); wrap.appendChild(label);
} document.body.appendChild(wrap);
} return wrap;
fileNames.push(rawName);
}
} }
if (fileNames.length === 0) return; /* ---------------- drag start (rows/cards) ---------------- */
export function fileDragStartHandler(event) {
const row = getRowEl(event.currentTarget);
if (!row) return;
const dragData = fileNames.length === 1 // Use current selection if present; otherwise drag just this rows file
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" } let names = getSelectedFileNames();
: { files: fileNames, sourceFolder: window.currentFolder || "root" }; if (names.length === 0) {
const single = getNameFromAny(row);
if (single) names = [single];
}
if (names.length === 0) return;
event.dataTransfer.setData("application/json", JSON.stringify(dragData)); const sourceFolder = window.currentFolder || 'root';
const payload = { files: names, sourceFolder };
let dragImage = document.createElement("div"); // primary payload
dragImage.style.display = "inline-flex"; event.dataTransfer.setData('application/json', JSON.stringify(payload));
dragImage.style.width = "auto"; // fallback (lets some environments read something human)
dragImage.style.maxWidth = "fit-content"; event.dataTransfer.setData('text/plain', names.join('\n'));
dragImage.style.padding = "6px 10px";
dragImage.style.backgroundColor = "#333";
dragImage.style.color = "#fff";
dragImage.style.border = "1px solid #555";
dragImage.style.borderRadius = "4px";
dragImage.style.alignItems = "center";
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
const icon = document.createElement("span");
icon.className = "material-icons";
icon.textContent = "insert_drive_file";
icon.style.marginRight = "4px";
const label = document.createElement("span");
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
dragImage.appendChild(icon);
dragImage.appendChild(label);
document.body.appendChild(dragImage); // nicer drag image
event.dataTransfer.setDragImage(dragImage, 5, 5); const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
setTimeout(() => { const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
document.body.removeChild(dragImage); event.dataTransfer.setDragImage(ghost, 6, 6);
}, 0); // clean up the ghost as soon as the browser has captured it
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
} }
/* ---------------- folder targets ---------------- */
export function folderDragOverHandler(event) { export function folderDragOverHandler(event) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.add("drop-hover"); event.currentTarget.classList.add('drop-hover');
} }
export function folderDragLeaveHandler(event) { export function folderDragLeaveHandler(event) {
event.currentTarget.classList.remove("drop-hover"); event.currentTarget.classList.remove('drop-hover');
} }
export function folderDropHandler(event) { export async function folderDropHandler(event) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.remove("drop-hover"); event.currentTarget.classList.remove('drop-hover');
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData; const dropFolder = event.currentTarget.getAttribute('data-folder')
|| event.currentTarget.getAttribute('data-dest-folder')
|| 'root';
// parse drag payload
let dragData = null;
try { try {
dragData = JSON.parse(event.dataTransfer.getData("application/json")); const raw = event.dataTransfer.getData('application/json') || '{}';
} catch (e) { dragData = JSON.parse(raw);
console.error("Invalid drag data"); } catch {
// ignore
}
if (!dragData) {
showToast('Invalid drag data.');
return; return;
} }
if (!dragData || !dragData.fileName) return;
fetch("/api/file/moveFiles.php", { // normalize names
method: "POST", let names = Array.isArray(dragData.files) ? dragData.files.slice()
credentials: "include", : dragData.fileName ? [dragData.fileName]
: [];
names = names.filter(v => typeof v === 'string' && v.length > 0);
if (names.length === 0) {
showToast('No files to move.');
return;
}
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
if (dropFolder === sourceFolder) {
showToast('Source and destination are the same.');
return;
}
// POST move
try {
const res = await fetch('/api/file/moveFiles.php', {
method: 'POST',
credentials: 'include',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content") 'Accept': 'application/json',
'X-CSRF-Token': window.csrfToken
}, },
body: JSON.stringify({ body: JSON.stringify({
source: dragData.sourceFolder, source: sourceFolder,
files: [dragData.fileName], files: names,
destination: dropFolder destination: dropFolder
}) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder);
} else {
showToast("Error moving file: " + (data.error || "Unknown error"));
}
})
.catch(error => {
console.error("Error moving file via drop:", error);
showToast("Error moving file.");
}); });
const data = await res.json().catch(() => ({}));
if (res.ok && data && data.success) {
const msg = (names.length === 1)
? `Moved "${names[0]}" to ${dropFolder}.`
: `Moved ${names.length} files to ${dropFolder}.`;
showToast(msg);
// Refresh whatever view the user is currently looking at
loadFileList(window.currentFolder || sourceFolder);
} else {
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
showToast('Error moving file(s): ' + err);
}
} catch (e) {
console.error('Error moving file(s):', e);
showToast('Error moving file(s).');
}
} }

View File

@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; } function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php // Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
let __ooCaps = { enabled: false, exts: new Set(), fetched: false }; let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
async function fetchOnlyOfficeCapsOnce() { async function fetchOnlyOfficeCapsOnce() {
if (__ooCaps.fetched) return __ooCaps; if (__ooCaps.fetched) return __ooCaps;
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
const j = await r.json(); const j = await r.json();
__ooCaps.enabled = !!j.enabled; __ooCaps.enabled = !!j.enabled;
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []); __ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
} }
} catch { /* ignore; keep defaults */ } } catch { /* ignore; keep defaults */ }
__ooCaps.fetched = true; __ooCaps.fetched = true;
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(fileName) {
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); } function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) { // ---- script/css single-load with timeout guards ----
let src =
srcFromConfig ||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
await loadScriptOnce(src);
}
async function openOnlyOffice(fileName, folder) {
let editor; // make visible to the whole function
try {
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
let cfg;
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
// Must be absolute
const docUrl = cfg?.document?.url;
const cbUrl = cfg?.editorConfig?.callbackUrl;
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
}
// Load DocsAPI if needed
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// Modal
const modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.classList.add('modal', 'editor-modal');
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">
${t("editing")}: ${escapeHTML(fileName)}
</h3>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">&times;</button>
</div>
<div class="editor-body" style="flex:1;min-height:200px">
<div id="oo-editor" style="width:100%;height:100%"></div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
modal.focus();
// Well fill this after wiring the toggle, so destroy() can unhook it
let removeThemeListener = () => {};
const destroy = () => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
try { modal.remove(); } catch {}
};
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
// Let DS request closing
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
// Initial theme
const isDark =
document.documentElement.classList.contains('dark-mode') ||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{},
cfg.editorConfig.customization,
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
);
// Launch editor
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
} catch (e) {
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
const _loadedScripts = new Set(); const _loadedScripts = new Set();
const _loadedCss = new Set(); const _loadedCss = new Set();
let _corePromise = null; let _corePromise = null;
function loadScriptOnce(url) { function loadScriptOnce(url, timeoutMs = 12000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (_loadedScripts.has(url)) return resolve(); if (_loadedScripts.has(url)) return resolve();
const s = document.createElement("script"); const s = document.createElement("script");
const timer = setTimeout(() => {
try { s.remove(); } catch { }
reject(new Error(`Timeout loading: ${url}`));
}, timeoutMs);
s.src = url; s.src = url;
s.async = true; s.async = true;
s.onload = () => { _loadedScripts.add(url); resolve(); }; s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Load failed: ${url}`)); s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
document.head.appendChild(s); document.head.appendChild(s);
}); });
} }
@@ -240,7 +143,6 @@ async function ensureCore() {
async function loadSingleMode(name) { async function loadSingleMode(name) {
const rel = MODE_URL[name]; const rel = MODE_URL[name];
if (!rel) return; if (!rel) return;
// prepend base if needed
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel)); const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url); await loadScriptOnce(url);
} }
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
} }
// Public helper for callers (we keep your existing function name in use): // Public helper for callers (we keep your existing function name in use):
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
// ==== /CodeMirror lazy loader =============================================== // ==== /CodeMirror lazy loader ===============================================
// ---- OO preconnect / prewarm ----
function injectOOPreconnect(origin) {
try {
if (!origin || !isAbsoluteHttpUrl(origin)) return;
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
document.head.appendChild(make('dns-prefetch'));
document.head.appendChild(make('preconnect'));
} catch { }
}
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
let src = srcFromConfig;
if (!src) {
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
} else {
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
}
}
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
// Try once; if it times out and we derived from origin, fall back to the default prefix path
try {
console.time('oo:api.js');
await loadScriptOnce(src);
} catch (e) {
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
} else {
throw e;
}
} finally {
console.timeEnd('oo:api.js');
}
}
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
const ALWAYS_WARM_OO = true; // warm EVERY time
const OO_WARM_MS = 300;
function ensureOoModalCss() {
const prev = document.getElementById('ooEditorModalCss');
if (prev) return;
const style = document.createElement('style');
style.id = 'ooEditorModalCss';
style.textContent = `
#ooEditorModal{
--oo-header-h: 40px;
--oo-header-pad-v: 12px;
--oo-header-pad-h: 18px;
--oo-logo-h: 26px; /* tweak logo size */
}
#ooEditorModal{
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
background:var(--oo-modal-bg,#111)!important;
}
/* Header: logo (left) + title (fill) + absolute close (right) */
#ooEditorModal .editor-header{
position:relative; display:flex; align-items:center; gap:12px;
min-height:var(--oo-header-h);
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
border-bottom:1px solid rgba(0,0,0,.15);
box-sizing:border-box;
}
#ooEditorModal .editor-logo{
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
display:block; user-select:none; -webkit-user-drag:none;
}
#ooEditorModal .editor-title{
margin:0; font-size:18px; font-weight:700; line-height:1.2;
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
flex:1 1 auto;
}
/* Your scoped close button style */
#ooEditorModal .editor-close-btn{
position:absolute; top:5px; right:10px;
display:flex; justify-content:center; align-items:center;
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
transition:all .3s ease-in-out;
}
#ooEditorModal .editor-close-btn:hover{
color:#fff; background-color:#ff4d4d;
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
}
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
#ooEditorModal .editor-body{
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
}
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
#ooEditorModal .oo-warm-overlay{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
}
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
`;
document.head.appendChild(style);
}
// Theme-aware background so theres no white/gray edge
function applyModalBg(modal){
const isDark = document.documentElement.classList.contains('dark-mode')
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
const cs = getComputedStyle(document.documentElement);
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|| (isDark ? '#121212' : '#ffffff');
modal.style.setProperty('--oo-modal-bg', bg);
}
function lockPageScroll(on){
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
}
function ensureOoFullscreenModal(){
ensureOoModalCss();
let modal = document.getElementById('ooEditorModal');
if (!modal){
modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.innerHTML = `
<div class="editor-header">
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
<h3 class="editor-title"></h3>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">&times;</button>
</div>
<div class="editor-body">
<div id="oo-editor"></div>
</div>
`;
document.body.appendChild(modal);
} else {
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
// ensure logo exists and is placed before title when reusing
const header = modal.querySelector('.editor-header');
if (!header.querySelector('.editor-logo')){
const img = document.createElement('img');
img.className = 'editor-logo';
img.src = '/assets/logo.svg';
img.alt = 'FileRise logo';
header.insertBefore(img, header.querySelector('.editor-title'));
} else {
// make sure order is logo -> title
const logo = header.querySelector('.editor-logo');
const title = header.querySelector('.editor-title');
if (logo.nextElementSibling !== title){
header.insertBefore(logo, title);
}
}
}
applyModalBg(modal);
modal.style.display = 'flex';
modal.focus();
lockPageScroll(true);
return modal;
}
// Overlay lives INSIDE the modal body
function setOoBusy(modal, on, label='Preparing editor…'){
if (!modal) return;
const body = modal.querySelector('.editor-body');
let ov = body.querySelector('.oo-warm-overlay');
if (on){
if (!ov){
ov = document.createElement('div');
ov.className = 'oo-warm-overlay';
ov.textContent = label;
body.appendChild(ov);
}
} else if (ov){
ov.remove();
}
}
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
async function warmDocServerOnce(cfg){
let host = null, warmEditor = null;
try{
host = document.createElement('div');
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
Object.assign(host.style, {
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
});
document.body.appendChild(host);
const warmCfg = JSON.parse(JSON.stringify(cfg));
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
await new Promise(res => setTimeout(res, OO_WARM_MS));
}catch{} finally{
try{ warmEditor?.destroyEditor?.(); }catch{}
try{ host?.remove(); }catch{}
}
}
// Full-screen OO open with hidden warm-up EVERY click, then real editor
async function openOnlyOffice(fileName, folder){
let editor = null;
let removeThemeListener = () => {};
let cfg = null;
let userClosed = false;
// Build our full-screen modal
const modal = ensureOoFullscreenModal();
const titleEl = modal.querySelector('.editor-title');
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
const destroy = (removeModal = true) => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
if (removeModal) { try { modal.remove(); } catch {} }
lockPageScroll(false);
};
const onClose = () => { userClosed = true; destroy(true); };
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
try{
// 1) Fetch config
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
// 2) Preconnect + load DocsAPI
injectOOPreconnect(cfg.documentServerOrigin || null);
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// 3) Theme + base events
const isDark = document.documentElement.classList.contains('dark-mode')
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
);
cfg.events.onRequestClose = () => onClose();
// 4) Warm EVERY click
if (ALWAYS_WARM_OO && !userClosed){
setOoBusy(modal, true); // overlay INSIDE modal body
await warmDocServerOnce(cfg);
if (userClosed) return;
}
// 5) Launch visible editor in full-screen modal
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching + keep modal bg in sync
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
applyModalBg(modal);
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
}catch(e){
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
destroy(true);
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
// ==== Editor (CodeMirror) path =============================================
function getModeForFile(fileName) { function getModeForFile(fileName) {
const dot = fileName.lastIndexOf("."); const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ""; const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
const normName = normalizeModeName(desiredMode) || "text/plain"; const normName = normalizeModeName(desiredMode) || "text/plain";
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode; const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
const cmOptions = { const cm = window.CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
{
lineNumbers: !forcePlainText, lineNumbers: !forcePlainText,
mode: initialMode, mode: initialMode,
theme, theme,
viewportMargin: forcePlainText ? 20 : Infinity, viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false lineWrapping: false
}; }
const editor = window.CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
cmOptions
); );
window.currentEditor = editor; window.currentEditor = cm;
setTimeout(adjustEditorSize, 50); setTimeout(adjustEditorSize, 50);
observeModalResize(modal); observeModalResize(modal);
// Font controls (now that editor exists) // Font controls (now that editor exists)
let currentFontSize = 14; let currentFontSize = 14;
const wrapper = editor.getWrapperElement(); const wrapper = cm.getWrapperElement();
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
decBtn.addEventListener("click", function () { decBtn.addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2); currentFontSize = Math.max(8, currentFontSize - 2);
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
}); });
incBtn.addEventListener("click", function () { incBtn.addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2); currentFontSize = Math.min(32, currentFontSize + 2);
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
}); });
// Save // Save
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
// Theme switch // Theme switch
function updateEditorTheme() { function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode"); const isDark = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDark ? "material-darker" : "default"); cm.setOption("theme", isDark ? "material-darker" : "default");
} }
const toggle = document.getElementById("darkModeToggle"); const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme); if (toggle) toggle.addEventListener("click", updateEditorTheme);
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
if (!canceled && !forcePlainText) { if (!canceled && !forcePlainText) {
const nn = normalizeModeName(desiredMode); const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) { if (nn && isModeRegistered(nn)) {
editor.setOption("mode", desiredMode); cm.setOption("mode", desiredMode);
} }
} }
}).catch(() => { }).catch(() => { /* stay in plain text */ });
// If the mode truly fails to load, we just stay in plain text
});
}); });
}) })
.catch(error => { .catch(error => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,246 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}'; import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
import {
handleDeleteSelected, handleCopySelected, handleMoveSelected,
handleDownloadZipSelected, handleExtractZipSelected,
renameFile, openCreateFileModal
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}'; import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}'; import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}'; import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}'; import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) { const MENU_ID = 'fileContextMenu';
let menu = document.getElementById("fileContextMenu");
if (!menu) { function qMenu() { return document.getElementById(MENU_ID); }
menu = document.createElement("div"); function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
menu.id = "fileContextMenu";
menu.style.position = "fixed"; // One-time: localize labels
menu.style.backgroundColor = "#fff"; function localizeMenu() {
menu.style.border = "1px solid #ccc"; const m = qMenu(); if (!m) return;
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)"; const map = {
menu.style.zIndex = "9999"; 'create_file': 'create_file',
menu.style.padding = "5px 0"; 'delete_selected': 'delete_selected',
menu.style.minWidth = "150px"; 'copy_selected': 'copy_selected',
document.body.appendChild(menu); 'move_selected': 'move_selected',
'download_zip': 'download_zip',
'extract_zip': 'extract_zip',
'tag_selected': 'tag_selected',
'preview': 'preview',
'edit': 'edit',
'rename': 'rename',
'tag_file': 'tag_file'
};
Object.entries(map).forEach(([action, key]) => {
const el = m.querySelector(`.mi[data-action="${action}"]`);
if (el) setText(el, key);
});
} }
menu.innerHTML = "";
menuItems.forEach(item => {
let menuItem = document.createElement("div");
menuItem.textContent = item.label;
menuItem.style.padding = "5px 15px";
menuItem.style.cursor = "pointer";
menuItem.addEventListener("mouseover", () => {
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
});
menuItem.addEventListener("mouseout", () => {
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
});
menu.style.left = x + "px"; // Show/hide items based on selection state
menu.style.top = y + "px"; function configureVisibility({ any, one, many, anyZip, canEdit }) {
menu.style.display = "block"; const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect(); const show = (sel, on) => sel.forEach(el => el.hidden = !on);
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) { show(m.querySelectorAll('[data-when="always"]'), true);
let newTop = viewportHeight - menuRect.height; show(m.querySelectorAll('[data-when="any"]'), any);
if (newTop < 0) newTop = 0; show(m.querySelectorAll('[data-when="one"]'), one);
menu.style.top = newTop + "px"; show(m.querySelectorAll('[data-when="many"]'), many);
show(m.querySelectorAll('[data-when="zip"]'), anyZip);
show(m.querySelectorAll('[data-when="can-edit"]'), canEdit);
// Hide separators at edges or duplicates
cleanupSeparators(m);
} }
function cleanupSeparators(menu) {
const kids = Array.from(menu.children);
let lastWasSep = true; // leading seps hidden
kids.forEach((el, i) => {
if (el.classList.contains('sep')) {
const hide = lastWasSep || (i === kids.length - 1);
el.hidden = hide || el.hidden; // keep hidden if already hidden by state
lastWasSep = !el.hidden;
} else if (!el.hidden) {
lastWasSep = false;
}
});
}
// Position menu within viewport
function placeMenu(x, y) {
const m = qMenu(); if (!m) return;
// make visible to measure
m.hidden = false;
m.style.left = '0px';
m.style.top = '0px';
// force a max-height via CSS fallback if styles didn't load yet
const pad = 8;
const vh = window.innerHeight, vw = window.innerWidth;
const mh = Math.min(vh - pad*2, 600); // JS fallback limit
m.style.maxHeight = mh + 'px';
// measure now that it's flow-visible
const r0 = m.getBoundingClientRect();
let nx = x, ny = y;
// If it would overflow right, shift left
if (nx + r0.width > vw - pad) nx = Math.max(pad, vw - r0.width - pad);
// If it would overflow bottom, try placing it above the cursor
if (ny + r0.height > vh - pad) {
const above = y - r0.height - 4;
ny = (above >= pad) ? above : Math.max(pad, vh - r0.height - pad);
}
// Guard top/left minimums
nx = Math.max(pad, nx);
ny = Math.max(pad, ny);
m.style.left = `${nx}px`;
m.style.top = `${ny}px`;
} }
export function hideFileContextMenu() { export function hideFileContextMenu() {
const menu = document.getElementById("fileContextMenu"); const m = qMenu();
if (menu) { if (m) m.hidden = true;
menu.style.display = "none";
} }
function currentSelection() {
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
// checkbox values are ESCAPED names (because buildFileTableRow used safeFileName)
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
const escSet = new Set(selectedEsc);
// map back to real file objects by comparing escaped(f.name)
const files = fileData.filter(f => escSet.has(escapeHTML(f.name)));
const any = files.length > 0;
const one = files.length === 1;
const many = files.length > 1;
const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
const file = one ? files[0] : null;
const canEditFlag = !!(file && canEditFile(file.name));
// also return the raw names if any caller needs them
return {
files, // <— real file objects for modals
all: files.map(f => f.name),
any, one, many, anyZip,
file,
canEdit: canEditFlag
};
} }
export function fileListContextMenuHandler(e) { export function fileListContextMenuHandler(e) {
e.preventDefault(); e.preventDefault();
let row = e.target.closest("tr"); // Check row if needed
const row = e.target.closest('tr');
if (row) { if (row) {
const checkbox = row.querySelector(".file-checkbox"); const cb = row.querySelector('.file-checkbox');
if (checkbox && !checkbox.checked) { if (cb && !cb.checked) {
checkbox.checked = true; cb.checked = true;
updateRowHighlight(checkbox); updateRowHighlight(cb);
} }
} }
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const state = currentSelection();
configureVisibility(state);
placeMenu(e.clientX, e.clientY);
let menuItems = [ // Stash for click handlers
{ label: t("create_file"), action: () => openCreateFileModal() }, window.__filr_ctx_state = state;
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
];
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
menuItems.push({
label: t("extract_zip"),
action: () => { handleExtractZipSelected(new Event("click")); }
});
} }
if (selected.length > 1) { // --- add near top ---
menuItems.push({ let __ctxBoundOnce = false;
label: t("tag_selected"),
action: () => {
const files = fileData.filter(f => selected.includes(f.name));
openMultiTagModal(files);
}
});
}
else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]);
menuItems.push({ function docClickClose(ev) {
label: t("preview"), const m = qMenu(); if (!m || m.hidden) return;
action: () => { if (!m.contains(ev.target)) hideFileContextMenu();
const folder = window.currentFolder || "root";
previewFile(buildPreviewUrl(folder, file.name), file.name);
} }
}); function docKeyClose(ev) {
if (ev.key === 'Escape') hideFileContextMenu();
if (canEditFile(file.name)) {
menuItems.push({
label: t("edit"),
action: () => { editFile(selected[0], window.currentFolder); }
});
} }
menuItems.push({ function menuClickDelegate(ev) {
label: t("rename"), const btn = ev.target.closest('.mi[data-action]');
action: () => { renameFile(selected[0], window.currentFolder); } if (!btn) return;
}); ev.stopPropagation();
menuItems.push({ // CLOSE MENU FIRST so it cant overlay the modal
label: t("tag_file"), hideFileContextMenu();
action: () => { openTagModal(file); }
}); const action = btn.dataset.action;
const s = window.__filr_ctx_state || currentSelection();
const folder = window.currentFolder || 'root';
switch (action) {
case 'create_file': openCreateFileModal(); break;
case 'delete_selected': handleDeleteSelected(new Event('click')); break;
case 'copy_selected': handleCopySelected(new Event('click')); break;
case 'move_selected': handleMoveSelected(new Event('click')); break;
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
case 'tag_selected':
openMultiTagModal(s.files); // s.files are the real file objects
break;
case 'preview':
if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
break;
case 'edit':
if (s.file && s.canEdit) editFile(s.file.name, folder);
break;
case 'rename':
if (s.file) renameFile(s.file.name, folder);
break;
case 'tag_file':
if (s.file) openTagModal(s.file);
break;
}
} }
showFileContextMenu(e.clientX, e.clientY, menuItems); // keep your renderFileTable wrapper as-is
}
export function bindFileListContextMenu() { export function bindFileListContextMenu() {
const fileListContainer = document.getElementById("fileList"); const container = document.getElementById('fileList');
if (fileListContainer) { const menu = qMenu();
fileListContainer.oncontextmenu = fileListContextMenuHandler; if (!container || !menu) return;
localizeMenu();
// Open on right click in the table
container.oncontextmenu = fileListContextMenuHandler;
// Bind once
if (!__ctxBoundOnce) {
document.addEventListener('click', docClickClose);
document.addEventListener('keydown', docKeyClose);
menu.addEventListener('click', menuClickDelegate); // handles actions
__ctxBoundOnce = true;
} }
} }
document.addEventListener("click", function (e) { // Rebind after table render (keeps your original behavior)
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
}
});
// Rebind context menu after file table render.
(function () { (function () {
const originalRenderFileTable = window.renderFileTable; const orig = window.renderFileTable;
if (typeof orig === 'function') {
window.renderFileTable = function (folder) { window.renderFileTable = function (folder) {
originalRenderFileTable(folder); orig(folder);
bindFileListContextMenu(); bindFileListContextMenu();
}; };
} else {
// If not present yet, bind once DOM is ready
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
}
})(); })();

View File

@@ -123,6 +123,21 @@ export function openShareModal(file, folder) {
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i; const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i; const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i; const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
const TXT_RE = /\.(txt|rtf|md|log)$/i;
function getIconForFile(name) {
const lower = (name || '').toLowerCase();
if (IMG_RE.test(lower)) return 'image';
if (VID_RE.test(lower)) return 'ondemand_video';
if (AUD_RE.test(lower)) return 'audiotrack';
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
if (ARCH_RE.test(lower)) return 'archive';
if (CODE_RE.test(lower)) return 'code';
if (TXT_RE.test(lower)) return 'description';
return 'insert_drive_file';
}
function ensureMediaModal() { function ensureMediaModal() {
let overlay = document.getElementById("filePreviewModal"); let overlay = document.getElementById("filePreviewModal");
@@ -145,116 +160,173 @@ function ensureMediaModal() {
const root = document.documentElement; const root = document.documentElement;
const styles = getComputedStyle(root); const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode'); const isDark = root.classList.contains('dark-mode');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff'); const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#2c2c2c' : '#ffffff');
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111'); const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)'; const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
const navFg = '#fff'; const navFg = '#fff';
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)'; const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
// fixed top bar; pad-right to avoid overlap with absolute close “×”
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal-content media-modal" style=" <div class="modal-content media-modal" style="
position: relative; position: relative;
max-width: 92vw; max-width: 92vw;
max-height: 92vh;
width: 92vw; width: 92vw;
max-height: 92vh;
height: 92vh;
box-sizing: border-box; box-sizing: border-box;
padding: 12px;
background: ${panelBg}; background: ${panelBg};
color: ${textCol}; color: ${textCol};
overflow: hidden; overflow: hidden;
border-radius: 10px; border-radius: 10px;
display:flex; flex-direction:column;
"> ">
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);"> <!-- Top bar -->
<!-- filename badge (top-left) --> <div class="media-topbar" style="
<div class="media-title-badge" style=" flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
position:absolute; top:8px; left:12px; max-width:60vw; height:44px; padding:6px 12px; padding-right:56px; gap:10px;
padding:4px 10px; border-radius:10px; border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'}; background:${panelBg};
color: ${isDark ? '#fff' : '#111'}; ">
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;"> <div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
<span class="material-icons title-icon" style="
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
insert_drive_file
</span>
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
</div> </div>
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
<!-- top-right actions row (aligned with your X at top:10px) -->
<div class="media-actions-bar" style="
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
<span class="status-chip" style=" <span class="status-chip" style="
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1; display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span> border:1px solid transparent; background:transparent; color:inherit;"></span>
<div class="action-group" style="display:flex; gap:6px;"></div> <div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
</div>
</div> </div>
<!-- your absolute close X --> <!-- Stage -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">&times;</span> <div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
<!-- centered media -->
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div> <div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
<!-- high-contrast prev/next --> <!-- prev/next = rounded rectangles with centered glyphs -->
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style=" <button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
position:absolute; left:8px; top:50%; transform:translateY(-50%); position:absolute; left:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1; height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder}; background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6); text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px); border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button> box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
<button class="nav-right" aria-label="${t('next')||'Next'}" style=" <button class="nav-right" aria-label="${t('next')||'Next'}" style="
position:absolute; right:8px; top:50%; transform:translateY(-50%); position:absolute; right:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1; height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder}; background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6); text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px); border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button> box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
</div> </div>
<!-- Absolute close “×” (like original), themed + hover behavior -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
position:absolute; top:8px; right:10px; z-index:1002;
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
">&times;</span>
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
// theme the close “×” for visibility + hover rules that match your site:
const closeBtn = overlay.querySelector("#closeFileModal");
function paintCloseBase() {
closeBtn.style.backgroundColor = 'transparent';
closeBtn.style.color = '#e11d48'; // base red X
closeBtn.style.boxShadow = 'none';
}
function onCloseHoverEnter() {
const dark = document.documentElement.classList.contains('dark-mode');
closeBtn.style.backgroundColor = '#ef4444'; // red fill
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
}
function onCloseHoverLeave() { paintCloseBase(); }
paintCloseBase();
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
function closeModal() { function closeModal() {
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {} try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey); if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay.remove(); overlay.remove();
} }
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal); closeBtn.addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
return overlay; return overlay;
} }
function setTitle(overlay, name) { function setTitle(overlay, name) {
const el = overlay.querySelector('.media-title-badge'); const textEl = overlay.querySelector('.title-text');
if (el) el.textContent = name || ''; const iconEl = overlay.querySelector('.title-icon');
if (textEl) {
textEl.textContent = name || '';
textEl.setAttribute('title', name || '');
}
if (iconEl) {
iconEl.textContent = getIconForFile(name);
// keep the icon legible in both themes
const dark = document.documentElement.classList.contains('dark-mode');
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
iconEl.style.opacity = dark ? '0.96' : '0.9';
}
} }
function makeMI(name, title) { // Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button'); const b = document.createElement('button');
b.className = `material-icons ${name}`; b.className = 'material-icons';
b.textContent = name; // Material Icons font b.textContent = name;
b.title = title; b.title = title;
const dark = document.documentElement.classList.contains('dark-mode');
Object.assign(b.style, { Object.assign(b.style, {
width: "32px", width: '32px',
height: "32px", height: '32px',
display: "flex", borderRadius: '8px',
alignItems: "center", display: 'flex',
justifyContent: "center", alignItems: 'center',
background: "rgba(0,0,0,.25)", justifyContent: 'center',
border: "1px solid rgba(255,255,255,.25)", border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
cursor: "pointer", background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
userSelect: "none", cursor: 'pointer',
fontSize: "20px", fontSize: '20px',
padding: "0", lineHeight: '1',
borderRadius: "8px", color: dark ? '#f5f5f5' : '#111',
color: "#fff", boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
lineHeight: "1"
}); });
b.addEventListener('mouseenter', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
});
b.addEventListener('mouseleave', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
});
return b; return b;
} }
function setNavVisibility(overlay, showPrev, showNext) { function setNavVisibility(overlay, showPrev, showNext) {
const prev = overlay.querySelector('.nav-left'); const prev = overlay.querySelector('.nav-left');
const next = overlay.querySelector('.nav-right'); const next = overlay.querySelector('.nav-right');
prev.style.display = showPrev ? 'inline-flex' : 'none'; prev.style.display = showPrev ? 'flex' : 'none';
next.style.display = showNext ? 'inline-flex' : 'none'; next.style.display = showNext ? 'flex' : 'none';
} }
function setRowWatchedBadge(name, watched) { function setRowWatchedBadge(name, watched) {
@@ -280,8 +352,8 @@ function setRowWatchedBadge(name, watched) {
export function previewFile(fileUrl, fileName) { export function previewFile(fileUrl, fileName) {
const overlay = ensureMediaModal(); const overlay = ensureMediaModal();
const container = overlay.querySelector(".file-preview-container"); const container = overlay.querySelector(".file-preview-container");
const actionWrap = overlay.querySelector(".media-actions-bar .action-group"); const actionWrap = overlay.querySelector(".media-right .action-group");
const statusChip = overlay.querySelector(".media-actions-bar .status-chip"); const statusChip = overlay.querySelector(".media-right .status-chip");
// replace nav buttons to clear old listeners // replace nav buttons to clear old listeners
let prevBtn = overlay.querySelector('.nav-left'); let prevBtn = overlay.querySelector('.nav-left');
@@ -320,10 +392,11 @@ export function previewFile(fileUrl, fileName) {
img.dataset.rotate = 0; img.dataset.rotate = 0;
container.appendChild(img); container.appendChild(img);
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In'); // topbar-aligned, theme-aware icons
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out'); const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left'); const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right'); const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
actionWrap.appendChild(zoomInBtn); actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn); actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft); actionWrap.appendChild(rotateLeft);
@@ -398,21 +471,19 @@ export function previewFile(fileUrl, fileName) {
/* -------------------- VIDEOS -------------------- */ /* -------------------- VIDEOS -------------------- */
if (isVideo) { if (isVideo) {
let video = document.createElement("video"); // let so we can rebind let video = document.createElement("video");
video.controls = true; video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw"; video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh"; video.style.maxHeight = "88vh";
video.style.objectFit = "contain"; video.style.objectFit = "contain";
container.appendChild(video); container.appendChild(video);
const markBtn = document.createElement('button'); // Top-right action icons (Material icons, theme-aware)
const clearBtn = document.createElement('button'); const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
markBtn.className = 'btn btn-sm btn-success'; const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
clearBtn.className = 'btn btn-sm btn-secondary'; actionWrap.appendChild(markBtnIcon);
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed"; actionWrap.appendChild(clearBtnIcon);
clearBtn.textContent = t("clear_progress") || "Clear progress";
actionWrap.appendChild(markBtn);
actionWrap.appendChild(clearBtn);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name)); const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video'; overlay.mediaType = 'video';
@@ -420,7 +491,14 @@ export function previewFile(fileUrl, fileName) {
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name)); overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1); setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); }; // Track which file is currently active
let currentName = name;
const setVideoSrc = (nm) => {
currentName = nm;
video.src = buildPreviewUrl(folder, nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000; const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0; let lastSaveAt = 0;
@@ -433,6 +511,7 @@ export function previewFile(fileUrl, fileName) {
return data && data.state ? data.state : null; return data && data.state ? data.state : null;
} catch { return null; } } catch { return null; }
} }
async function sendProgress({nm, seconds, duration, completed, clear}) { async function sendProgress({nm, seconds, duration, completed, clear}) {
try { try {
pending = true; pending = true;
@@ -445,50 +524,58 @@ export function previewFile(fileUrl, fileName) {
const data = await res.json(); const data = await res.json();
pending = false; pending = false;
return data; return data;
} catch (e) { pending = false; console.error(e); return null; } } catch (e) {
pending = false;
console.error(e);
return null;
} }
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`; const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) { function renderStatus(state) {
if (!statusChip) return; if (!statusChip) return;
// Completed // Completed
if (state && state.completed) { if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓'; statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block'; statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)'; statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)'; statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e'; statusChip.style.color = '#22c55e';
markBtn.style.display = 'none'; markBtnIcon.style.display = 'none';
clearBtn.style.display = ''; clearBtnIcon.style.display = '';
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset'; clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return; return;
} }
// In progress // In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) { if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100))); const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`; statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block'; statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
statusChip.style.background = 'rgba(250,204,21,.15)'; const dark = document.documentElement.classList.contains('dark-mode');
statusChip.style.color = '#facc15'; const ORANGE_HEX = '#ea580c';
markBtn.style.display = ''; statusChip.style.color = ORANGE_HEX;
clearBtn.style.display = ''; statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset'; statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return; return;
} }
// No progress // No progress
statusChip.style.display = 'none'; statusChip.style.display = 'none';
markBtn.style.display = ''; markBtnIcon.style.display = '';
clearBtn.style.display = 'none'; clearBtnIcon.style.display = 'none';
} }
function bindVideoEvents(nm) { // ---- Event handlers (use currentName instead of rebinding per file) ----
const nv = video.cloneNode(true);
video.replaceWith(nv);
video = nv;
video.addEventListener("loadedmetadata", async () => { video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try { try {
const state = await getProgress(nm); const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) { if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
@@ -511,8 +598,11 @@ setFileProgressBadge(nm, seconds, duration);
const now = Date.now(); const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return; if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now; lastSaveAt = now;
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0); const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0); const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration }); sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration); setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {} try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
@@ -520,6 +610,7 @@ setFileProgressBadge(nm, seconds, duration);
}); });
video.addEventListener("ended", async () => { video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0); const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true }); await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {} try { localStorage.removeItem(lsKey(nm)); } catch {}
@@ -528,35 +619,40 @@ setFileProgressBadge(nm, seconds, duration);
renderStatus({ seconds: duration, duration, completed: true }); renderStatus({ seconds: duration, duration, completed: true });
}); });
markBtn.onclick = async () => { markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0); const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true }); await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed"); showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true); setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true }); renderStatus({ seconds: duration, duration, completed: true });
}; };
clearBtn.onclick = async () => {
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true }); await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {} try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared"); showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false); setFileWatchedBadge(nm, false);
renderStatus(null); renderStatus(null);
}; };
}
const navigate = (dir) => { const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return; if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length; overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name; const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm); setVideoSrc(nm);
bindVideoEvents(nm); renderStatus(null);
}; };
if (videos.length > 1) { if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); }); prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); }); nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => { const onKey = (e) => {
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; } if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1); if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1); if (e.key === "ArrowRight") navigate(+1);
}; };
@@ -566,7 +662,6 @@ setFileProgressBadge(nm, seconds, duration);
setVideoSrc(name); setVideoSrc(name);
renderStatus(null); renderStatus(null);
bindVideoEvents(name);
overlay.style.display = "flex"; overlay.style.display = "flex";
return; return;
} }

View File

@@ -1,172 +1,214 @@
// fileTags.js // fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
// This module provides functions for opening the tag modal,
// adding tags to files (with a global tag store for reuse),
// updating the file row display with tag badges,
// filtering the file list by tag, and persisting tag data.
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}'; import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
export function openTagModal(file) { // -------------------- state --------------------
// Create the modal element. let __singleInit = false;
let modal = document.createElement('div'); let __multiInit = false;
modal.id = 'tagModal'; let currentFile = null;
modal.className = 'modal';
modal.innerHTML = ` // Global store (preserve existing behavior)
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
}
// -------------------- ensure DOM (create-once-if-missing) --------------------
function ensureSingleTagModal() {
// de-dupe if something already injected multiples
const all = document.querySelectorAll('#tagModal');
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
let modal = document.getElementById('tagModal');
if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="tagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;"> <div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style=" <h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
margin:0; ${t('tag_file')}
display:inline-block;
max-width: calc(100% - 40px);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
">
${t("tag_file")}: ${escapeHTML(file.name)}
</h3> </h3>
<span id="closeTagModal" class="editor-close-btn">&times;</span> <span id="closeTagModal" class="editor-close-btn">×</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label> <label for="tagNameInput">${t('tag_name')}</label>
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/> <input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br> <br><br>
<label for="tagColorInput">${t("tag_name")}</label> <label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/> <input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br> <br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"> <div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<!-- Custom tag options will be populated here -->
</div>
<br> <br>
<div style="text-align:right;"> <div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button> <button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
</div> </div>
<div id="currentTags" style="margin-top:10px; font-size:0.9em;"> <div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
<!-- Existing tags will be listed here -->
</div> </div>
</div> </div>
</div> </div>
`; `);
document.body.appendChild(modal); modal = document.getElementById('tagModal');
modal.style.display = 'block';
updateCustomTagDropdown();
document.getElementById('closeTagModal').addEventListener('click', () => {
modal.remove();
});
updateTagModalDisplay(file);
document.getElementById('tagNameInput').addEventListener('input', (e) => {
updateCustomTagDropdown(e.target.value);
});
document.getElementById('saveTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('tagNameInput').value.trim();
const tagColor = document.getElementById('tagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
} }
addTagToFile(file, { name: tagName, color: tagColor }); return modal;
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
document.getElementById('tagNameInput').value = '';
updateCustomTagDropdown();
});
} }
/** function ensureMultiTagModal() {
* Open a modal to tag multiple files. const all = document.querySelectorAll('#multiTagModal');
* @param {Array} files - Array of file objects to tag. if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
*/
export function openMultiTagModal(files) { let modal = document.getElementById('multiTagModal');
let modal = document.createElement('div'); if (!modal) {
modal.id = 'multiTagModal'; document.body.insertAdjacentHTML('beforeend', `
modal.className = 'modal'; <div id="multiTagModal" class="modal" style="display:none">
modal.innerHTML = `
<div class="modal-content" style="width:450px; max-width:90vw;"> <div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3> <h3 id="multiTagTitle" style="margin:0;"></h3>
<span id="closeMultiTagModal" class="editor-close-btn">&times;</span> <span id="closeMultiTagModal" class="editor-close-btn">×</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label> <label for="multiTagNameInput">${t('tag_name')}</label>
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/> <input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br> <br><br>
<label for="multiTagColorInput">Tag Color:</label> <label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/> <input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br> <br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"> <div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<!-- Custom tag options will be populated here -->
</div>
<br> <br>
<div style="text-align:right;"> <div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button> <button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
</div> </div>
</div> </div>
</div> </div>
`; </div>
document.body.appendChild(modal); `);
modal.style.display = 'block'; modal = document.getElementById('multiTagModal');
updateMultiCustomTagDropdown();
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
modal.remove();
});
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
updateMultiCustomTagDropdown(e.target.value);
});
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('multiTagNameInput').value.trim();
const tagColor = document.getElementById('multiTagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
} }
return modal;
}
// -------------------- init (bind once) --------------------
function initSingleModalOnce() {
if (__singleInit) return;
const modal = ensureSingleTagModal();
const closeBtn = document.getElementById('closeTagModal');
const saveBtn = document.getElementById('saveTagBtn');
const nameInp = document.getElementById('tagNameInput');
// Close handlers
closeBtn?.addEventListener('click', hideTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideTagModal(); // click backdrop
});
// Input filter for dropdown
nameInp?.addEventListener('input', (e) => updateCustomTagDropdown(e.target.value));
// Save handler
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('tagNameInput')?.value || '').trim();
const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
if (!currentFile) return;
addTagToFile(currentFile, { name: tagName, color: tagColor });
updateTagModalDisplay(currentFile);
updateFileRowTagDisplay(currentFile);
saveFileTags(currentFile);
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
else renderFileTable(window.currentFolder);
const inp = document.getElementById('tagNameInput');
if (inp) inp.value = '';
updateCustomTagDropdown('');
});
__singleInit = true;
}
function initMultiModalOnce() {
if (__multiInit) return;
const modal = ensureMultiTagModal();
const closeBtn = document.getElementById('closeMultiTagModal');
const saveBtn = document.getElementById('saveMultiTagBtn');
const nameInp = document.getElementById('multiTagNameInput');
closeBtn?.addEventListener('click', hideMultiTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMultiTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideMultiTagModal();
});
nameInp?.addEventListener('input', (e) => updateMultiCustomTagDropdown(e.target.value));
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim();
const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
const files = (window.__multiTagFiles || []);
files.forEach(file => { files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor }); addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file); updateFileRowTagDisplay(file);
saveFileTags(file); saveFileTags(file);
}); });
modal.remove();
if (window.viewMode === 'gallery') { hideMultiTagModal();
renderGalleryView(window.currentFolder); if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
} else { else renderFileTable(window.currentFolder);
renderFileTable(window.currentFolder);
}
}); });
__multiInit = true;
} }
/** // -------------------- open/close APIs --------------------
* Update the custom dropdown for multi-tag modal. export function openTagModal(file) {
* Similar to updateCustomTagDropdown but includes a remove icon. initSingleModalOnce();
*/ const modal = document.getElementById('tagModal');
const title = document.getElementById('tagModalTitle');
currentFile = file || null;
if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`;
updateCustomTagDropdown('');
updateTagModalDisplay(file);
modal.style.display = 'block';
}
export function hideTagModal() {
const modal = document.getElementById('tagModal');
if (modal) modal.style.display = 'none';
}
export function openMultiTagModal(files) {
initMultiModalOnce();
const modal = document.getElementById('multiTagModal');
const title = document.getElementById('multiTagTitle');
window.__multiTagFiles = Array.isArray(files) ? files : [];
if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`;
updateMultiCustomTagDropdown('');
modal.style.display = 'block';
}
export function hideMultiTagModal() {
const modal = document.getElementById('multiTagModal');
if (modal) modal.style.display = 'none';
}
// -------------------- dropdown + UI helpers --------------------
function updateMultiCustomTagDropdown(filterText = "") { function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown"); const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return; if (!dropdown) return;
dropdown.innerHTML = ""; dropdown.innerHTML = "";
let tags = window.globalTags || []; let tags = window.globalTags || [];
if (filterText) { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) { if (tags.length > 0) {
tags.forEach(tag => { tags.forEach(tag => {
const item = document.createElement("div"); const item = document.createElement("div");
item.style.cursor = "pointer"; item.style.cursor = "pointer";
item.style.padding = "5px"; item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee"; item.style.borderBottom = "1px solid #eee";
// Display colored square and tag name with remove icon.
item.innerHTML = ` item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span> <span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)} ${escapeHTML(tag.name)}
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
`; `;
item.addEventListener("click", function(e) { item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return; if (e.target.classList.contains("global-remove")) return;
document.getElementById("multiTagNameInput").value = tag.name; const n = document.getElementById("multiTagNameInput");
document.getElementById("multiTagColorInput").value = tag.color; const c = document.getElementById("multiTagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
}); });
item.querySelector('.global-remove').addEventListener("click", function(e){ item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation(); e.stopPropagation();
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
dropdown.appendChild(item); dropdown.appendChild(item);
}); });
} else { } else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>"; dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
} }
} }
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
if (!dropdown) return; if (!dropdown) return;
dropdown.innerHTML = ""; dropdown.innerHTML = "";
let tags = window.globalTags || []; let tags = window.globalTags || [];
if (filterText) { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) { if (tags.length > 0) {
tags.forEach(tag => { tags.forEach(tag => {
const item = document.createElement("div"); const item = document.createElement("div");
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
`; `;
item.addEventListener("click", function(e){ item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return; if (e.target.classList.contains('global-remove')) return;
document.getElementById("tagNameInput").value = tag.name; const n = document.getElementById("tagNameInput");
document.getElementById("tagColorInput").value = tag.color; const c = document.getElementById("tagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
}); });
item.querySelector('.global-remove').addEventListener("click", function(e){ item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation(); e.stopPropagation();
@@ -219,7 +263,7 @@ function updateCustomTagDropdown(filterText = "") {
dropdown.appendChild(item); dropdown.appendChild(item);
}); });
} else { } else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>"; dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
} }
} }
@@ -227,8 +271,8 @@ function updateCustomTagDropdown(filterText = "") {
function updateTagModalDisplay(file) { function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags'); const container = document.getElementById('currentTags');
if (!container) return; if (!container) return;
container.innerHTML = '<strong>Current Tags:</strong> '; container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
if (file.tags && file.tags.length > 0) { if (file?.tags?.length) {
file.tags.forEach(tag => { file.tags.forEach(tag => {
const tagElem = document.createElement('span'); const tagElem = document.createElement('span');
tagElem.textContent = tag.name; tagElem.textContent = tag.name;
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
tagElem.style.borderRadius = '3px'; tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block'; tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative'; tagElem.style.position = 'relative';
const removeIcon = document.createElement('span'); const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕'; removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold'; removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px'; removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer'; removeIcon.style.cursor = 'pointer';
removeIcon.addEventListener('click', (e) => { removeIcon.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
removeTagFromFile(file, tag.name); removeTagFromFile(file, tag.name);
}); });
tagElem.appendChild(removeIcon); tagElem.appendChild(removeIcon);
container.appendChild(tagElem); container.appendChild(tagElem);
}); });
} else { } else {
container.innerHTML += 'None'; container.innerHTML += (t('none') || 'None');
} }
} }
function removeTagFromFile(file, tagName) { function removeTagFromFile(file, tagName) {
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase()); file.tags = (file.tags || []).filter(tg => tg.name.toLowerCase() !== tagName.toLowerCase());
updateTagModalDisplay(file); updateTagModalDisplay(file);
updateFileRowTagDisplay(file); updateFileRowTagDisplay(file);
saveFileTags(file); saveFileTags(file);
} }
/**
* Remove a tag from the global tag store.
* This function updates window.globalTags and calls the backend endpoint
* to remove the tag from the persistent store.
*/
function removeGlobalTag(tagName) { function removeGlobalTag(tagName) {
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase()); window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName); saveGlobalTagRemoval(tagName);
} }
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) { function saveGlobalTagRemoval(tagName) {
fetch("/api/file/saveFileTag.php", { fetch("/api/file/saveFileTag.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json", body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete: tagName,
tags: []
}) })
}) .then(r => r.json())
.then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success && data.globalTags) {
console.log("Global tag removed:", tagName);
if (data.globalTags) {
window.globalTags = data.globalTags; window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
} } else if (!data.success) {
} else {
console.error("Error removing global tag:", data.error); console.error("Error removing global tag:", data.error);
} }
}) })
.catch(err => { .catch(err => console.error("Error removing global tag:", err));
console.error("Error removing global tag:", err);
});
} }
// Global store for reusable tags. // -------------------- exports kept from your original --------------------
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try {
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
} catch (e) { }
}
// New function to load global tags from the server's persistent JSON.
export function loadGlobalTags() { export function loadGlobalTags() {
fetch("/api/file/getFileTag.php", { credentials: "include" }) fetch("/api/file/getFileTag.php", { credentials: "include" })
.then(response => { .then(r => r.ok ? r.json() : [])
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
return [];
}
return response.json();
})
.then(data => { .then(data => {
window.globalTags = data; window.globalTags = data || [];
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
@@ -346,34 +353,25 @@ export function loadGlobalTags() {
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
}); });
} }
loadGlobalTags(); loadGlobalTags();
// Add (or update) a tag in the file object.
export function addTagToFile(file, tag) { export function addTagToFile(file, tag) {
if (!file.tags) { if (!file.tags) file.tags = [];
file.tags = []; const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
} if (exists) exists.color = tag.color; else file.tags.push(tag);
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (exists) { const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
exists.color = tag.color;
} else {
file.tags.push(tag);
}
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) { if (!globalExists) {
window.globalTags.push(tag); window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
} }
} }
// Update the file row (in table view) to show tag badges.
export function updateFileRowTagDisplay(file) { export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`); const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
console.log('Updating tags for rows:', rows);
rows.forEach(row => { rows.forEach(row => {
let cell = row.querySelector('.file-name-cell'); let cell = row.querySelector('.file-name-cell');
if (cell) { if (!cell) return;
let badgeContainer = cell.querySelector('.tag-badges'); let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) { if (!badgeContainer) {
badgeContainer = document.createElement('div'); badgeContainer = document.createElement('div');
@@ -383,8 +381,7 @@ export function updateFileRowTagDisplay(file) {
cell.appendChild(badgeContainer); cell.appendChild(badgeContainer);
} }
badgeContainer.innerHTML = ''; badgeContainer.innerHTML = '';
if (file.tags && file.tags.length > 0) { (file.tags || []).forEach(tag => {
file.tags.forEach(tag => {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.textContent = tag.name; badge.textContent = tag.name;
badge.style.backgroundColor = tag.color; badge.style.backgroundColor = tag.color;
@@ -396,92 +393,73 @@ export function updateFileRowTagDisplay(file) {
badge.style.verticalAlign = 'middle'; badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge); badgeContainer.appendChild(badge);
}); });
}
}
}); });
} }
export function initTagSearch() { export function initTagSearch() {
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
if (searchInput) { if (!searchInput) return;
let tagSearchInput = document.getElementById('tagSearchInput'); let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) { if (!tagSearchInput) {
tagSearchInput = document.createElement('input'); tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput'; tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = 'Filter by tag'; tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
tagSearchInput.style.marginLeft = '10px'; tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px'; tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling); searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => { tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase(); window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) { if (window.currentFolder) renderFileTable(window.currentFolder);
renderFileTable(window.currentFolder);
}
}); });
} }
} }
}
export function filterFilesByTag(files) { export function filterFilesByTag(files) {
if (window.currentTagFilter && window.currentTagFilter !== '') { const q = (window.currentTagFilter || '').trim().toLowerCase();
return files.filter(file => { if (!q) return files;
if (file.tags && file.tags.length > 0) { return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
}
return false;
});
}
return files;
} }
function updateGlobalTagList() { function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList"); const dataList = document.getElementById("globalTagList");
if (dataList) { if (!dataList) return;
dataList.innerHTML = ""; dataList.innerHTML = "";
window.globalTags.forEach(tag => { (window.globalTags || []).forEach(tag => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = tag.name; option.value = tag.name;
dataList.appendChild(option); dataList.appendChild(option);
}); });
} }
}
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) { export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root"; const folder = file.folder || "root";
const payload = { const payload = deleteGlobal && tagToDelete ? {
folder: folder, folder: "root",
file: file.name, file: "global",
tags: file.tags deleteGlobal: true,
}; tagToDelete,
if (deleteGlobal && tagToDelete) { tags: []
payload.file = "global"; } : { folder, file: file.name, tags: file.tags };
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
fetch("/api/file/saveFileTag.php", { fetch("/api/file/saveFileTag.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
.then(response => response.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
console.log("Tags saved:", data);
if (data.globalTags) { if (data.globalTags) {
window.globalTags = data.globalTags; window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
} }
updateGlobalTagList();
} else { } else {
console.error("Error saving tags:", data.error); console.error("Error saving tags:", data.error);
} }
}) })
.catch(err => { .catch(err => console.error("Error saving tags:", err));
console.error("Error saving tags:", err);
});
} }

File diff suppressed because it is too large Load Diff

View File

@@ -233,7 +233,7 @@ const translations = {
"error_generating_recovery_code": "Error generating recovery code", "error_generating_recovery_code": "Error generating recovery code",
"error_loading_qr_code": "Error loading QR code.", "error_loading_qr_code": "Error loading QR code.",
"error_disabling_totp_setting": "Error disabling TOTP setting", "error_disabling_totp_setting": "Error disabling TOTP setting",
"user_management": "User Management", "user_management": "Users, Groups & Access",
"add_user": "Add User", "add_user": "Add User",
"remove_user": "Remove User", "remove_user": "Remove User",
"user_permissions": "User Permissions", "user_permissions": "User Permissions",
@@ -268,7 +268,7 @@ const translations = {
"columns": "Columns", "columns": "Columns",
"row_height": "Row Height", "row_height": "Row Height",
"api_docs": "API Docs", "api_docs": "API Docs",
"show_folders_above_files": "Show folders above files", "show_folders_above_files": "Show folder strip above list",
"display": "Display", "display": "Display",
"create_file": "Create File", "create_file": "Create File",
"create_new_file": "Create New File", "create_new_file": "Create New File",
@@ -312,7 +312,32 @@ const translations = {
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"watched": "Watched", "watched": "Watched",
"reset_progress": "Reset Progress" "reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders",
"show_inline_folders": "Show folders as rows above files",
"name": "Name",
"size": "Size",
"modified": "Modified",
"created": "Created",
"owner": "Owner"
}, },
es: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -62,7 +62,15 @@ async function ensureToastReady() {
} }
function isDemoHost() { function isDemoHost() {
// Handles optional "www." just in case try {
const cfg = window.__FR_SITE_CFG__ || {};
if (typeof cfg.demoMode !== 'undefined') {
return !!cfg.demoMode;
}
} catch {
// ignore
}
// Fallback for older configs / direct demo host:
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net'; return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
} }
@@ -70,15 +78,27 @@ function showLoginTip(message) {
const tip = document.getElementById('fr-login-tip'); const tip = document.getElementById('fr-login-tip');
if (!tip) return; if (!tip) return;
tip.innerHTML = ''; // clear tip.innerHTML = ''; // clear
if (message) tip.append(document.createTextNode(message));
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') { if (message) {
const line = document.createElement('div'); line.style.marginTop = '6px'; tip.append(document.createTextNode(message));
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; }; }
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
document.createTextNode(' · pass: '), mk('demo')); if (isDemoHost()) {
const line = document.createElement('div');
line.style.marginTop = '6px';
const mk = t => {
const k = document.createElement('code');
k.textContent = t;
return k;
};
line.append(
document.createTextNode('Demo login — user: '), mk('demo'),
document.createTextNode(' · pass: '), mk('demo')
);
tip.append(line); tip.append(line);
} }
tip.style.display = 'block'; // reveal without shifting layout
tip.style.display = 'block';
} }
async function hideOverlaySmoothly(overlay) { async function hideOverlaySmoothly(overlay) {
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
return p.then(r => r.clone()); return p.then(r => r.clone());
}; };
// ---- Safe redirect helper (prevents open redirects) ----
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
const candidate = new URL(str, window.location.origin);
// Enforce same-origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// Limit to http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return relative URL
return candidate.pathname + candidate.search + candidate.hash;
} catch {
return fallback;
}
}
// Gentle toast normalizer (compatible with showToast(message, duration)) // Gentle toast normalizer (compatible with showToast(message, duration))
const origToast = window.showToast; const origToast = window.showToast;
if (typeof origToast === 'function' && !origToast.__frWrapped) { if (typeof origToast === 'function' && !origToast.__frWrapped) {
@@ -406,23 +452,75 @@ function bindDarkMode() {
// Always keep <title> correct early (no visual flicker) // Always keep <title> correct early (no visual flicker)
document.title = title; document.title = title;
// --- Header logo (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const customLogoUrl = branding.customLogoUrl || "";
const logoImg = document.querySelector('.header-logo img');
if (logoImg) {
if (customLogoUrl) {
logoImg.setAttribute('src', customLogoUrl);
logoImg.setAttribute('alt', 'Site logo');
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
logoImg.setAttribute('alt', 'FileRise');
}
}
} catch (e) {
// non-fatal; ignore branding issues
}
// --- Header colors (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const root = document.documentElement;
const light = branding.headerBgLight || '';
const dark = branding.headerBgDark || '';
if (light) root.style.setProperty('--header-bg-light', light);
else root.style.removeProperty('--header-bg-light');
if (dark) root.style.setProperty('--header-bg-dark', dark);
else root.style.removeProperty('--header-bg-dark');
} catch (e) {
// non-fatal
}
// --- Login options (apply in BOTH phases so login page is correct) --- // --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {}; const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) { // be tolerant to key variants just in case
if (disableForm) { const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
row.setAttribute('hidden', ''); const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
row.style.display = ''; // don't leave display:none lying around const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else { } else {
row.removeAttribute('hidden'); loginWrap.setAttribute('hidden', '');
row.style.display = ''; loginWrap.style.display = '';
} }
} }
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : ''; const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]'); const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : ''; if (basic) basic.style.display = disableBasic ? 'none' : '';
@@ -474,11 +572,13 @@ function bindDarkMode() {
const r = await fetch('/api/siteConfig.php', { credentials: 'include' }); const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
const j = await r.json().catch(() => ({})); const j = await r.json().catch(() => ({}));
window.__FR_SITE_CFG__ = j || {}; window.__FR_SITE_CFG__ = j || {};
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
// Early pass: title + login options (skip touching <h1> to avoid flicker) // Early pass: title + login options (skip touching <h1> to avoid flicker)
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' }); applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
return window.__FR_SITE_CFG__; return window.__FR_SITE_CFG__;
} catch { } catch {
window.__FR_SITE_CFG__ = {}; window.__FR_SITE_CFG__ = {};
window.__FR_DEMO__ = false;
applySiteConfig({}, { phase: 'early' }); applySiteConfig({}, { phase: 'early' });
return null; return null;
} }
@@ -831,6 +931,19 @@ function bindDarkMode() {
}); });
} }
function afterLogin() { function afterLogin() {
// If index.html was opened with ?redirect=<url>, honor that first
try {
const url = new URL(window.location.href);
const raw = url.searchParams.get('redirect');
const safe = sanitizeRedirect(raw, { fallback: null });
if (safe) {
window.location.href = safe;
return;
}
} catch {
// ignore URL/param issues and fall back to normal behavior
}
const start = Date.now(); const start = Date.now();
(function poll() { (function poll() {
checkAuth().then(({ authed }) => { checkAuth().then(({ authed }) => {
@@ -1037,6 +1150,21 @@ function bindDarkMode() {
if (login) login.style.display = ''; if (login) login.style.display = '';
// …wire stuff… // …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' }); applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
(() => {
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
const qp = new URLSearchParams(location.search);
if (onlyOIDC && qp.get('noauto') !== '1') {
const btn = document.getElementById('oidcLoginBtn');
if (btn) setTimeout(() => btn.click(), 250);
}
})();
await revealAppAndHideOverlay(); await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons'); const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden'; if (hb) hb.style.visibility = 'hidden';

382
public/js/portal-login.js Normal file
View File

@@ -0,0 +1,382 @@
// public/js/portal-login.js
// -------- URL helpers --------
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
// Resolve against current origin so relative URLs work
const candidate = new URL(str, window.location.origin);
// 1) Must stay on the same origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// 2) Only allow http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return a relative URL (prevents host changes)
return candidate.pathname + candidate.search + candidate.hash;
} catch {
return fallback;
}
}
function getRedirectTarget() {
try {
const url = new URL(window.location.href);
const raw = url.searchParams.get('redirect');
// Default fallback: root
let target = sanitizeRedirect(raw, { fallback: '/' });
// If there was no *usable* redirect but we have a portal slug,
// send them back to that portal by default.
if (!target || target === '/') {
const slug = getPortalSlugFromUrl();
if (slug) {
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
}
}
return target || '/';
} catch {
return '/';
}
}
function getPortalSlugFromUrl() {
try {
const url = new URL(window.location.href);
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
let slug = url.searchParams.get('slug');
if (slug && slug.trim()) {
console.log('portal-login: slug from top-level param =', slug.trim());
return slug.trim();
}
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
const redirect = url.searchParams.get('redirect');
if (redirect) {
console.log('portal-login: raw redirect param =', redirect);
try {
const redirectUrl = new URL(redirect, window.location.origin);
// 2a) ?slug=... in redirect
const innerSlug = redirectUrl.searchParams.get('slug');
if (innerSlug && innerSlug.trim()) {
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
return innerSlug.trim();
}
// 2b) Pretty path /portal/<slug> in redirect
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
if (pathMatch && pathMatch[1]) {
const fromPath = pathMatch[1].trim();
console.log('portal-login: slug from redirect path =', fromPath);
return fromPath;
}
} catch (err) {
console.warn('portal-login: failed to parse redirect URL', err);
}
// 2c) Fallback regex on redirect string
const m = redirect.match(/[?&]slug=([^&]+)/);
if (m && m[1]) {
const decoded = decodeURIComponent(m[1]).trim();
console.log('portal-login: slug from redirect regex =', decoded);
return decoded;
}
}
// 3) Legacy fallback on current query string
const qs = window.location.search || '';
const m2 = qs.match(/[?&]slug=([^&]+)/);
if (m2 && m2[1]) {
const decoded2 = decodeURIComponent(m2[1]).trim();
console.log('portal-login: slug from own query regex =', decoded2);
return decoded2;
}
console.log('portal-login: no slug found');
return '';
} catch (err) {
console.warn('portal-login: getPortalSlugFromUrl error', err);
const qs = window.location.search || '';
const m = qs.match(/[?&]slug=([^&]+)/);
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
}
}
// --- CSRF helpers (same pattern as portal.js) ---
function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
try {
localStorage.setItem('csrf', token);
} catch { /* ignore */ }
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
function getCsrfToken() {
return (
window.csrfToken ||
(document.querySelector('meta[name="csrf-token"]')?.content) ||
''
);
}
async function loadCsrfToken() {
try {
const res = await fetch('/api/auth/token.php', {
method: 'GET',
credentials: 'include'
});
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
} catch (e) {
console.warn('portal-login: failed to load CSRF token', e);
}
}
// --- UI helpers ---
function showError(msg) {
const box = document.getElementById('portalLoginError');
if (!box) return;
box.textContent = msg || 'Login failed.';
box.classList.add('show');
}
function clearError() {
const box = document.getElementById('portalLoginError');
if (!box) return;
box.textContent = '';
box.classList.remove('show');
}
// -------- Portal meta (title + accent) --------
async function fetchPortalMeta(slug) {
if (!slug) return null;
console.log('portal-login: calling publicMeta.php for slug', slug);
try {
const res = await fetch(
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
{ method: 'GET', credentials: 'include' }
);
const text = await res.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = {};
}
if (!res.ok || !data || !data.success || !data.portal) {
console.warn('portal-login: publicMeta not ok', res.status, data);
return null;
}
return data.portal;
} catch (e) {
console.warn('portal-login: failed to load portal meta', e);
return null;
}
}
function applyPortalBranding(portal) {
if (!portal) return;
const title =
(portal.title && portal.title.trim()) ||
portal.label ||
portal.slug ||
'Client portal';
const headingEl = document.getElementById('portalLoginTitle');
const subtitleEl = document.getElementById('portalLoginSubtitle');
const footerEl = document.getElementById('portalLoginFooter');
if (headingEl) {
headingEl.textContent = 'Sign in to ' + title;
}
if (subtitleEl) {
subtitleEl.textContent = 'to access this client portal';
}
// Footer text from portal metadata, if provided
if (footerEl) {
const ft = (portal.footerText && portal.footerText.trim()) || '';
if (ft) {
footerEl.textContent = ft;
footerEl.style.display = 'block';
} else {
footerEl.textContent = '';
footerEl.style.display = 'none';
}
}
// Document title
try {
document.title = 'Sign in ' + title;
} catch { /* ignore */ }
// Accent: portal brandColor -> CSS var
const brand = portal.brandColor && portal.brandColor.trim();
if (brand) {
document.documentElement.style.setProperty('--portal-accent', brand);
}
// Reapply card/button accent after we know portal color
applyAccentFromTheme();
}
// --- Accent (card + button) ---
function applyAccentFromTheme() {
const card = document.querySelector('.portal-login-card');
const btn = document.getElementById('portalLoginSubmit');
const rootStyles = getComputedStyle(document.documentElement);
// Prefer per-portal accent if present
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
if (!accent) {
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
}
if (card) {
card.style.borderTop = `3px solid ${accent}`;
}
if (btn) {
btn.style.backgroundColor = accent;
btn.style.borderColor = accent;
}
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
metaTheme.setAttribute('content', accent);
}
}
// --- Login call (JSON -> auth.php) ---
async function doLogin(username, password) {
const csrf = getCsrfToken() || '';
const payload = {
username,
password
};
if (csrf) {
payload.csrf_token = csrf;
}
const res = await fetch('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const text = await res.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = {};
}
if (!res.ok) {
const msg = body.error || body.message || text || 'Login failed.';
const err = new Error(msg);
err.status = res.status;
throw err;
}
if (body.success === false || body.error || body.logged_in === false) {
throw new Error(body.error || 'Invalid username or password.');
}
return body;
}
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
const form = document.getElementById('portalLoginForm');
const userEl = document.getElementById('portalLoginUser');
const passEl = document.getElementById('portalLoginPass');
const btn = document.getElementById('portalLoginSubmit');
// Accent first (fallback to global accent)
applyAccentFromTheme();
// Try to load portal meta (title + brand color) using slug
const slug = getPortalSlugFromUrl();
console.log('portal-login: computed slug =', slug);
if (slug) {
fetchPortalMeta(slug).then(portal => {
if (portal) {
console.log('portal-login: got portal meta for', slug, portal);
applyPortalBranding(portal);
}
});
}
// Pre-load CSRF (for auth.php)
loadCsrfToken().catch(() => {});
if (!form || !userEl || !passEl || !btn) return;
// Focus username
userEl.focus();
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const username = userEl.value.trim();
const password = passEl.value;
if (!username || !password) {
showError('Username and password are required');
return;
}
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
await doLogin(username, password);
const target = getRedirectTarget();
window.location.href = target;
} catch (err) {
console.error('portal-login: auth failed', err);
showError(err.message || 'Login failed. Please try again.');
btn.disabled = false;
btn.textContent = 'Sign in';
}
});
});

716
public/js/portal.js Normal file
View File

@@ -0,0 +1,716 @@
// public/js/portal.js
// Standalone client portal logic no imports from main app JS to avoid DOM coupling.
let portal = null;
let portalFormDone = false;
// --- Portal helpers: folder + download flag -----------------
function portalFolder() {
if (!portal) return 'root';
return portal.folder || portal.targetFolder || portal.path || 'root';
}
function portalCanDownload() {
if (!portal) return false;
// Prefer explicit flags if present
if (typeof portal.allowDownload !== 'undefined') {
return !!portal.allowDownload;
}
if (typeof portal.allowDownloads !== 'undefined') {
return !!portal.allowDownloads;
}
// Fallback: uploadOnly = true => no downloads
if (typeof portal.uploadOnly !== 'undefined') {
return !portal.uploadOnly;
}
// Default: allow downloads
return true;
}
// ----------------- DOM helpers / status -----------------
function qs(id) {
return document.getElementById(id);
}
function setStatus(msg, isError = false) {
const el = qs('portalStatus');
if (!el) return;
el.textContent = msg || '';
el.classList.toggle('text-danger', !!isError);
if (!isError) {
el.classList.add('text-muted');
}
}
// ----------------- Form submit -----------------
async function submitPortalForm(slug, formData) {
const payload = {
slug,
form: formData
};
const headers = { 'X-CSRF-Token': getCsrfToken() || '' };
const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers);
if (!res || !res.success) {
throw new Error((res && res.error) || 'Error saving form.');
}
}
// ----------------- Toast -----------------
function showToast(message) {
const toast = document.getElementById('customToast');
if (!toast) {
console.warn('Toast:', message);
return;
}
toast.textContent = message;
toast.style.display = 'block';
// Force reflow
void toast.offsetWidth;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
toast.style.display = 'none';
}, 200);
}, 2500);
}
// ----------------- Fetch wrapper -----------------
async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) {
const options = {
method,
credentials: 'include',
headers: { ...customHeaders }
};
if (data && !(data instanceof FormData)) {
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
options.body = data;
}
const res = await fetch(url, options);
const text = await res.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
if (!res.ok) {
throw payload;
}
return payload;
}
// ----------------- Portal form wiring -----------------
function setupPortalForm(slug) {
const formSection = qs('portalFormSection');
const uploadSection = qs('portalUploadSection');
if (!portal || !portal.requireForm) {
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
}
const key = 'portalFormDone:' + slug;
if (sessionStorage.getItem(key) === '1') {
portalFormDone = true;
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
}
portalFormDone = false;
if (formSection) formSection.style.display = 'block';
if (uploadSection) uploadSection.style.opacity = '0.5';
const nameEl = qs('portalFormName');
const emailEl = qs('portalFormEmail');
const refEl = qs('portalFormReference');
const notesEl = qs('portalFormNotes');
const submitBtn = qs('portalFormSubmit');
const fd = portal.formDefaults || {};
if (nameEl && fd.name && !nameEl.value) {
nameEl.value = fd.name;
}
if (emailEl && fd.email && !emailEl.value) {
emailEl.value = fd.email;
} else if (emailEl && portal.clientEmail && !emailEl.value) {
// fallback to clientEmail
emailEl.value = portal.clientEmail;
}
if (refEl && fd.reference && !refEl.value) {
refEl.value = fd.reference;
}
if (notesEl && fd.notes && !notesEl.value) {
notesEl.value = fd.notes;
}
if (!submitBtn) return;
submitBtn.onclick = async () => {
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const reference = refEl ? refEl.value.trim() : '';
const notes = notesEl ? notesEl.value.trim() : '';
const req = portal.formRequired || {};
const missing = [];
if (req.name && !name) missing.push('name');
if (req.email && !email) missing.push('email');
if (req.reference && !reference) missing.push('reference');
if (req.notes && !notes) missing.push('notes');
if (missing.length) {
showToast('Please fill in: ' + missing.join(', ') + '.');
return;
}
// default behavior when no specific required flags:
if (!req.name && !req.email && !req.reference && !req.notes) {
if (!name && !email) {
showToast('Please provide at least a name or email.');
return;
}
}
try {
await submitPortalForm(slug, { name, email, reference, notes });
portalFormDone = true;
sessionStorage.setItem(key, '1');
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
showToast('Thank you. You can now upload files.');
} catch (e) {
console.error(e);
showToast('Error saving your info. Please try again.');
}
};
}
// ----------------- CSRF helpers -----------------
function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
try {
localStorage.setItem('csrf', token);
} catch {
// ignore
}
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
function getCsrfToken() {
return window.csrfToken || (document.querySelector('meta[name="csrf-token"]')?.content) || '';
}
async function loadCsrfToken() {
const res = await fetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
}
// ----------------- Auth -----------------
async function ensureAuthenticated() {
try {
const data = await sendRequest('/api/auth/checkAuth.php', 'GET');
if (!data || !data.username) {
// redirect to main UI/login; after login, user can re-open portal link
const target = encodeURIComponent(window.location.href);
window.location.href = '/portal-login.html?redirect=' + target;
return null;
}
const lbl = qs('portalUserLabel');
if (lbl) {
lbl.textContent = data.username || '';
}
return data;
} catch (e) {
const target = encodeURIComponent(window.location.href);
window.location.href = '/portal-login.html?redirect=' + target;
return null;
}
}
// ----------------- Portal fetch + render -----------------
async function fetchPortal(slug) {
setStatus('Loading portal details…');
try {
const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET');
if (!data || !data.success || !data.portal) {
throw new Error((data && data.error) || 'Portal not found.');
}
portal = data.portal;
return portal;
} catch (e) {
console.error(e);
setStatus('This portal could not be found or is no longer available.', true);
showToast('Portal not found or expired.');
return null;
}
}
function renderPortalInfo() {
if (!portal) return;
const titleEl = qs('portalTitle');
const descEl = qs('portalDescription');
const subtitleEl = qs('portalSubtitle');
const brandEl = document.getElementById('portalBrandHeading');
const footerEl = document.getElementById('portalFooter');
const drop = qs('portalDropzone');
const card = document.querySelector('.portal-card');
const formBtn = qs('portalFormSubmit');
const refreshBtn = qs('portalRefreshBtn');
const filesSection = qs('portalFilesSection');
const heading = portal.title && portal.title.trim()
? portal.title.trim()
: (portal.label || portal.slug || 'Client portal');
if (titleEl) titleEl.textContent = heading;
if (brandEl) brandEl.textContent = heading;
if (descEl) {
if (portal.introText && portal.introText.trim()) {
descEl.textContent = portal.introText.trim();
} else {
const folder = portalFolder();
descEl.textContent = 'Files you upload here go directly into: ' + folder;
}
}
if (subtitleEl) {
const parts = [];
if (portal.uploadOnly) parts.push('upload only');
if (portalCanDownload()) parts.push('download allowed');
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
}
if (footerEl) {
footerEl.textContent = portal.footerText && portal.footerText.trim()
? portal.footerText.trim()
: '';
}
const color = portal.brandColor && portal.brandColor.trim();
if (color) {
// expose brand color as a CSS variable for gallery styling
document.documentElement.style.setProperty('--portal-accent', color);
if (drop) {
drop.style.borderColor = color;
}
if (card) {
card.style.borderTop = '3px solid ' + color;
}
if (formBtn) {
formBtn.style.backgroundColor = color;
formBtn.style.borderColor = color;
}
if (refreshBtn) {
refreshBtn.style.borderColor = color;
refreshBtn.style.color = color;
}
}
// Show/hide files section based on download capability
if (filesSection) {
filesSection.style.display = portalCanDownload() ? 'block' : 'none';
}
}
// ----------------- File helpers for gallery -----------------
function formatFileSizeLabel(f) {
// API currently returns f.size as a human-readable string, so prefer that
if (f && f.size) return f.size;
return '';
}
function fileExtLabel(name) {
if (!name) return 'FILE';
const parts = name.split('.');
if (parts.length < 2) return 'FILE';
const ext = parts.pop().trim().toUpperCase();
if (!ext) return 'FILE';
return ext.length <= 4 ? ext : ext.slice(0, 4);
}
function isImageName(name) {
if (!name) return false;
return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name);
}
// ----------------- Load files for portal gallery -----------------
async function loadPortalFiles() {
if (!portal || !portalCanDownload()) return;
const listEl = qs('portalFilesList');
if (!listEl) return;
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
try {
const folder = portalFolder();
const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET');
if (!data || data.error) {
const msg = (data && data.error) ? data.error : 'Error loading files.';
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
return;
}
// Normalize files: handle both array and object-return shapes
let files = [];
if (Array.isArray(data.files)) {
files = data.files;
} else if (data.files && typeof data.files === 'object') {
files = Object.entries(data.files).map(([name, meta]) => {
const f = meta || {};
f.name = name;
return f;
});
}
if (!files.length) {
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
return;
}
const accent = portal.brandColor && portal.brandColor.trim();
listEl.innerHTML = '';
listEl.classList.add('portal-files-grid'); // gallery layout
const MAX = 24;
const slice = files.slice(0, MAX);
slice.forEach(f => {
const card = document.createElement('div');
card.className = 'portal-file-card';
const icon = document.createElement('div');
icon.className = 'portal-file-card-icon';
const main = document.createElement('div');
main.className = 'portal-file-card-main';
const nameEl = document.createElement('div');
nameEl.className = 'portal-file-card-name';
nameEl.textContent = f.name || 'Unnamed file';
const metaEl = document.createElement('div');
metaEl.className = 'portal-file-card-meta text-muted';
metaEl.textContent = formatFileSizeLabel(f);
main.appendChild(nameEl);
main.appendChild(metaEl);
const actions = document.createElement('div');
actions.className = 'portal-file-card-actions';
// Thumbnail vs extension badge
const fname = f.name || '';
const folder = portalFolder();
if (isImageName(fname)) {
const thumbUrl =
'/api/file/download.php?folder=' +
encodeURIComponent(folder) +
'&file=' + encodeURIComponent(fname) +
'&inline=1&t=' + Date.now();
const img = document.createElement('img');
img.src = thumbUrl;
img.alt = fname;
// 🔧 constrain image so it doesn't fill the whole list
img.style.maxWidth = '100%';
img.style.maxHeight = '120px';
img.style.objectFit = 'cover';
img.style.display = 'block';
img.style.borderRadius = '6px';
icon.appendChild(img);
} else {
icon.textContent = fileExtLabel(fname);
}
if (accent) {
icon.style.borderColor = accent;
}
if (portalCanDownload()) {
const a = document.createElement('a');
a.href = '/api/file/download.php?folder=' +
encodeURIComponent(folder) +
'&file=' + encodeURIComponent(fname);
a.textContent = 'Download';
a.className = 'portal-file-card-download';
a.target = '_blank';
a.rel = 'noopener';
actions.appendChild(a);
}
card.appendChild(icon);
card.appendChild(main);
card.appendChild(actions);
listEl.appendChild(card);
});
if (files.length > MAX) {
const more = document.createElement('div');
more.className = 'portal-files-more text-muted';
more.textContent = 'And ' + (files.length - MAX) + ' more…';
listEl.appendChild(more);
}
} catch (e) {
console.error(e);
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
}
}
// ----------------- Upload -----------------
async function uploadFiles(fileList) {
if (!portal || !fileList || !fileList.length) return;
if (portal.requireForm && !portalFormDone) {
showToast('Please fill in your details before uploading.');
return;
}
const files = Array.from(fileList);
const folder = portalFolder();
setStatus('Uploading ' + files.length + ' file(s)…');
let successCount = 0;
let failureCount = 0;
for (const file of files) {
const form = new FormData();
const csrf = getCsrfToken() || '';
// Match main upload.js
form.append('file[]', file);
form.append('folder', folder);
if (csrf) {
form.append('upload_token', csrf); // legacy alias, but your controller supports it
}
let retried = false;
while (true) {
try {
const resp = await fetch('/api/upload/upload.php', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrf || ''
},
body: form
});
const text = await resp.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = {};
}
if (data && data.csrf_expired && data.csrf_token) {
setCsrfToken(data.csrf_token);
if (!retried) {
retried = true;
continue;
}
}
if (!resp.ok || (data && data.error)) {
failureCount++;
console.error('Upload error:', data || text);
} else {
successCount++;
}
break;
} catch (e) {
console.error('Upload error:', e);
failureCount++;
break;
}
}
}
if (successCount && !failureCount) {
setStatus('Uploaded ' + successCount + ' file(s).');
showToast('Upload complete.');
} else if (successCount && failureCount) {
setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true);
showToast('Some files failed to upload.');
} else {
setStatus('Upload failed.', true);
showToast('Upload failed.');
}
if (portalCanDownload()) {
loadPortalFiles();
}
}
// ----------------- Upload UI wiring -----------------
function wireUploadUI() {
const drop = qs('portalDropzone');
const input = qs('portalFileInput');
const refreshBtn = qs('portalRefreshBtn');
if (drop && input) {
drop.addEventListener('click', () => input.click());
input.addEventListener('change', (e) => {
const files = e.target.files;
if (files && files.length) {
uploadFiles(files);
input.value = '';
}
});
['dragenter', 'dragover'].forEach(ev => {
drop.addEventListener(ev, e => {
e.preventDefault();
e.stopPropagation();
drop.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
drop.addEventListener(ev, e => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove('dragover');
});
});
drop.addEventListener('drop', e => {
const dt = e.dataTransfer;
if (!dt || !dt.files || !dt.files.length) return;
uploadFiles(dt.files);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadPortalFiles();
});
}
}
// ----------------- Slug + init -----------------
function getPortalSlugFromUrl() {
try {
const url = new URL(window.location.href);
// 1) Normal case: slug is directly in query (?slug=portal-xxxxx)
let slug = url.searchParams.get('slug');
if (slug && slug.trim()) {
return slug.trim();
}
// 2) Pretty URL: /portal/<slug>
// e.g. /portal/portal-h46ozd
const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i);
if (pathMatch && pathMatch[1]) {
return pathMatch[1].trim();
}
// 3) Fallback: slug inside redirect param
// e.g. ?redirect=/portal.html?slug=portal-h46ozd
const redirect = url.searchParams.get('redirect');
if (redirect) {
try {
const redirectUrl = new URL(redirect, window.location.origin);
const innerSlug = redirectUrl.searchParams.get('slug');
if (innerSlug && innerSlug.trim()) {
return innerSlug.trim();
}
} catch {
// ignore parse errors
}
const m = redirect.match(/[?&]slug=([^&]+)/);
if (m && m[1]) {
return decodeURIComponent(m[1]).trim();
}
}
// 4) Final fallback: old regex on our own query string
const qs = window.location.search || '';
const m2 = qs.match(/[?&]slug=([^&]+)/);
return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : '';
} catch {
const qs = window.location.search || '';
const m = qs.match(/[?&]slug=([^&]+)/);
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
}
}
async function initPortal() {
const slug = getPortalSlugFromUrl();
if (!slug) {
setStatus('Missing portal slug.', true);
showToast('Portal slug missing in URL.');
return;
}
try {
await loadCsrfToken();
} catch (e) {
console.warn('CSRF load failed (may be fine if unauthenticated yet).', e);
}
const auth = await ensureAuthenticated();
if (!auth) return;
const p = await fetchPortal(slug);
if (!p) return;
renderPortalInfo();
setupPortalForm(slug);
wireUploadUI();
if (portalCanDownload()) {
loadPortalFiles();
}
setStatus('Ready.');
}
document.addEventListener('DOMContentLoaded', () => {
initPortal().catch(err => {
console.error(err);
setStatus('Unexpected error initializing portal.', true);
showToast('Unexpected error loading portal.');
});
});

View File

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

View File

@@ -3,8 +3,243 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}'; import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
// --- Lightweight tracking of in-progress resumable uploads (per user) ---
const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
function getCurrentUserKey() {
// Try a few globals; fall back to browser profile
const u =
(window.currentUser && String(window.currentUser)) ||
(window.appUser && String(window.appUser)) ||
(window.username && String(window.username)) ||
'';
return u || 'anon';
}
function loadResumableDraftsAll() {
try {
const raw = localStorage.getItem(RESUMABLE_DRAFTS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
console.warn('Failed to read resumable drafts from localStorage', e);
return {};
}
}
function saveResumableDraftsAll(all) {
try {
localStorage.setItem(RESUMABLE_DRAFTS_KEY, JSON.stringify(all));
} catch (e) {
console.warn('Failed to persist resumable drafts to localStorage', e);
}
}
// --- Single file-picker trigger guard (prevents multiple OS dialogs) ---
let _lastFilePickerOpen = 0;
function triggerFilePickerOnce() {
const now = Date.now();
// ignore any extra calls within 400ms of the last open
if (now - _lastFilePickerOpen < 400) return;
_lastFilePickerOpen = now;
const fi = document.getElementById('file');
if (fi) {
fi.click();
}
}
// Wire the "Choose files" button so it always uses the guarded trigger
function wireChooseButton() {
const btn = document.getElementById('customChooseBtn');
if (!btn || btn.__uploadBound) return;
btn.__uploadBound = true;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // don't let it bubble to the drop-area click handler
triggerFilePickerOnce();
});
}
function wireFileInputChange(fileInput) {
if (!fileInput || fileInput.__uploadChangeBound) return;
fileInput.__uploadChangeBound = true;
// For file picker, remove directory attributes so only files can be chosen.
fileInput.removeAttribute("webkitdirectory");
fileInput.removeAttribute("mozdirectory");
fileInput.removeAttribute("directory");
fileInput.setAttribute("multiple", "");
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
_currentResumableIds.clear(); // <--- add this
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
if (!all[userKey] || typeof all[userKey] !== 'object') {
all[userKey] = {};
}
const drafts = all[userKey];
return { all, userKey, drafts };
}
// Upsert / update a record for this resumable file
function upsertResumableDraft(file, percent) {
if (!file || !file.uniqueIdentifier) return;
const { all, userKey, drafts } = getUserDraftContext();
const id = file.uniqueIdentifier;
const folder = window.currentFolder || 'root';
const name = file.fileName || file.name || 'Unnamed file';
const size = file.size || 0;
const prev = drafts[id] || {};
const p = Math.max(0, Math.min(100, Math.floor(percent || 0)));
// Avoid hammering localStorage if nothing substantially changed
if (prev.lastPercent !== undefined && Math.abs(p - prev.lastPercent) < 1) {
return;
}
drafts[id] = {
identifier: id,
fileName: name,
size,
folder,
lastPercent: p,
updatedAt: Date.now()
};
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
// Remove a single draft by identifier
function clearResumableDraft(identifier) {
if (!identifier) return;
const { all, userKey, drafts } = getUserDraftContext();
if (drafts[identifier]) {
delete drafts[identifier];
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Optionally clear all drafts for the current folder (used on full success)
function clearResumableDraftsForFolder(folder) {
const { all, userKey, drafts } = getUserDraftContext();
const f = folder || 'root';
let changed = false;
for (const [id, rec] of Object.entries(drafts)) {
if (!rec || typeof rec !== 'object') continue;
if (rec.folder === f) {
delete drafts[id];
changed = true;
}
}
if (changed) {
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Show a small banner if there is any in-progress resumable upload for this folder
function showResumableDraftBanner() {
const uploadCard = document.getElementById('uploadCard');
if (!uploadCard) return;
// Remove any existing banner first
const existing = document.getElementById('resumableDraftBanner');
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
const { drafts } = getUserDraftContext();
const folder = window.currentFolder || 'root';
const candidates = Object.values(drafts)
.filter(d =>
d &&
d.folder === folder &&
typeof d.lastPercent === 'number' &&
d.lastPercent > 0 &&
d.lastPercent < 100
)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
if (!candidates.length) {
return; // nothing to show
}
const latest = candidates[0];
const count = candidates.length;
const countText =
count === 1
? 'You have a partially uploaded file'
: `You have ${count} partially uploaded files. Latest:`;
const banner = document.createElement('div');
banner.id = 'resumableDraftBanner';
banner.className = 'upload-resume-banner';
banner.innerHTML = `
<div class="upload-resume-banner-inner">
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
<span class="upload-resume-text">
${countText}
<strong>${escapeHTML(latest.fileName)}</strong>
(~${latest.lastPercent}%).
Choose it again from your device to resume.
</span>
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
</div>
`;
const dismissBtn = banner.querySelector('.upload-resume-dismiss-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
// Clear all resumable hints for this folder when the user dismisses.
clearResumableDraftsForFolder(folder);
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
});
}
// Insert at top of uploadCard
uploadCard.insertBefore(banner, uploadCard.firstChild);
}
/* ----------------------------------------------------- /* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code) Helpers for DragandDrop Folder Uploads (Original Code)
----------------------------------------------------- */ ----------------------------------------------------- */
@@ -82,7 +317,8 @@ function getFilesFromDataTransferItems(items) {
function setDropAreaDefault() { function setDropAreaDefault() {
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) { if (!dropArea) return;
dropArea.innerHTML = ` dropArea.innerHTML = `
<div id="uploadInstruction" class="upload-instruction"> <div id="uploadInstruction" class="upload-instruction">
${t("upload_instruction")} ${t("upload_instruction")}
@@ -96,9 +332,20 @@ function setDropAreaDefault() {
</div> </div>
</div> </div>
<!-- File input for file picker (files only) --> <!-- File input for file picker (files only) -->
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" /> <input
type="file"
id="file"
name="file[]"
class="form-control-file"
multiple
style="opacity:0; position:absolute; width:1px; height:1px;"
/>
`; `;
}
// After rebuilding markup, re-wire controls:
const fileInput = dropArea.querySelector('#file');
wireFileInputChange(fileInput);
wireChooseButton();
} }
function adjustFolderHelpExpansion() { function adjustFolderHelpExpansion() {
@@ -437,6 +684,7 @@ const useResumable = true;
let resumableInstance = null; let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready let _pendingPickedFiles = []; // files picked before library/instance ready
let _resumableReady = false; let _resumableReady = false;
let _currentResumableIds = new Set();
// Make init async-safe; it resolves when Resumable is constructed // Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() { async function initResumableUpload() {
@@ -455,7 +703,7 @@ async function initResumableUpload() {
chunkSize: 1.5 * 1024 * 1024, chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3, simultaneousUploads: 3,
forceChunkSize: true, forceChunkSize: true,
testChunks: false, testChunks: true,
withCredentials: true, withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken }, headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({ query: () => ({
@@ -473,17 +721,19 @@ async function initResumableUpload() {
resumableInstance.opts.query.upload_token = window.csrfToken; resumableInstance.opts.query.upload_token = window.csrfToken;
} }
const fileInput = document.getElementById("file");
if (fileInput) {
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
}
});
}
resumableInstance.on("fileAdded", function (file) { resumableInstance.on("fileAdded", function (file) {
// Build a stable per-file key
const id =
file.uniqueIdentifier ||
((file.fileName || file.name || '') + ':' + (file.size || 0));
// If we've already seen this id in the current batch, skip wiring it again
if (_currentResumableIds.has(id)) {
return;
}
_currentResumableIds.add(id);
// Initialize custom paused flag // Initialize custom paused flag
file.paused = false; file.paused = false;
@@ -492,6 +742,11 @@ async function initResumableUpload() {
window.selectedFiles = []; window.selectedFiles = [];
} }
window.selectedFiles.push(file); window.selectedFiles.push(file);
// Track as in-progress draft at 0%
upsertResumableDraft(file, 0);
showResumableDraftBanner();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside. // Check if a wrapper already exists; if not, create one with a UL inside.
@@ -519,8 +774,40 @@ async function initResumableUpload() {
resumableInstance.on("fileProgress", function (file) { resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1 const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100); let percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
// Never persist a full 100% from progress alone.
// If the tab dies here, we still want it to look resumable.
if (percent >= 100) percent = 99;
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
const bytesUploaded = progress * file.size;
const spd = bytesUploaded / elapsed;
if (spd < 1024) speed = spd.toFixed(0) + " B/s";
else if (spd < 1048576) speed = (spd / 1024).toFixed(1) + " KB/s";
else speed = (spd / 1048576).toFixed(1) + " MB/s";
}
li.progressBar.innerText = percent + "% (" + speed + ")";
} else {
li.progressBar.style.width = "100%";
li.progressBar.innerHTML =
'<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
}
}
if (li && li.progressBar) { if (li && li.progressBar) {
if (percent < 99) { if (percent < 99) {
li.progressBar.style.width = percent + "%"; li.progressBar.style.width = percent + "%";
@@ -552,6 +839,7 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false; pauseResumeBtn.disabled = false;
} }
} }
upsertResumableDraft(file, percent);
}); });
resumableInstance.on("fileSuccess", function (file, message) { resumableInstance.on("fileSuccess", function (file, message) {
@@ -588,8 +876,11 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none"; if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000); setTimeout(() => li.remove(), 5000);
} }
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
// This file finished successfully, remove its draft record
clearResumableDraft(file.uniqueIdentifier);
showResumableDraftBanner();
}); });
@@ -607,18 +898,22 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false; pauseResumeBtn.disabled = false;
} }
showToast("Error uploading file: " + file.fileName); showToast("Error uploading file: " + file.fileName);
// Treat errored file as no longer resumable (for now) and clear its hint
showResumableDraftBanner();
}); });
resumableInstance.on("complete", function () { resumableInstance.on("complete", function () {
// If any file is marked with an error, leave the list intact. // If any file is marked with an error, leave the list intact.
const hasError = window.selectedFiles.some(f => f.isError); const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
if (!hasError) { if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds. // All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => { setTimeout(() => {
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer) {
progressContainer.innerHTML = ""; progressContainer.innerHTML = "";
}
window.selectedFiles = []; window.selectedFiles = [];
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -627,6 +922,15 @@ async function initResumableUpload() {
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); if (dropArea) setDropAreaDefault();
// IMPORTANT: clear Resumable's internal file list so the next upload
// doesn't think there are still resumable files queued.
if (resumableInstance) {
// cancel() after completion just resets internal state; no chunks are deleted server-side.
resumableInstance.cancel();
}
clearResumableDraftsForFolder(window.currentFolder || 'root');
showResumableDraftBanner();
}, 5000); }, 5000);
} else { } else {
showToast("Some files failed to upload. Please check the list."); showToast("Some files failed to upload. Please check the list.");
@@ -650,11 +954,34 @@ function submitFiles(allFiles) {
const f = window.currentFolder || "root"; const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; } try { return decodeURIComponent(f); } catch { return f; }
})(); })();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (!progressContainer) {
console.warn("submitFiles called but #uploadProgressContainer not found");
return;
}
// --- Ensure there are progress list items for these files ---
let listItems = progressContainer.querySelectorAll("li.upload-progress-item");
if (!listItems.length) {
// Guarantee each file has a stable uploadIndex
allFiles.forEach((file, index) => {
if (file.uploadIndex === undefined || file.uploadIndex === null) {
file.uploadIndex = index;
}
});
// Build the UI rows for these files
// This will also set window.selectedFiles and fileInfoContainer, etc.
processFiles(allFiles);
// Re-query now that processFiles has populated the DOM
listItems = progressContainer.querySelectorAll("li.upload-progress-item");
}
const progressElements = {}; const progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => { listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item; progressElements[item.dataset.uploadIndex] = item;
}); });
@@ -680,7 +1007,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) { if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100); currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000; const elapsed = (Date.now() - li.startTime) / 1000;
let speed = ""; let speed = "";
if (elapsed > 0) { if (elapsed > 0) {
@@ -721,7 +1048,7 @@ function submitFiles(allFiles) {
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success // real success
if (li) { if (li && li.progressBar) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none"; if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -730,25 +1057,26 @@ function submitFiles(allFiles) {
} else { } else {
// real failure // real failure
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
allSucceeded = false; allSucceeded = false;
} }
if (file.isClipboard) { if (file.isClipboard) {
setTimeout(() => { setTimeout(() => {
window.selectedFiles = []; window.selectedFiles = [];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = ""; if (pc) pc.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
}, 5000); }, 5000);
} }
// ─── Only now count this chunk as finished ─────────────────── // ─── Only now count this upload as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length; const succeededCount = uploadResults.filter(Boolean).length;
@@ -762,7 +1090,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("error", function () { xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -778,7 +1106,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () { xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Aborted"; li.progressBar.innerText = "Aborted";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -808,18 +1136,21 @@ if (finishedCount === allFiles.length) {
}) })
.map(s => s.trim().toLowerCase()) .map(s => s.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
let overallSuccess = true; let overallSuccess = true;
let succeeded = 0; let succeeded = 0;
allFiles.forEach(file => { allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase(); const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath); const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) { if (!uploadResults[file.uploadIndex] ||
(!hadRelative && !serverFiles.includes(clientFileName))) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
overallSuccess = false; overallSuccess = false;
} else if (li) { } else if (li) {
succeeded++; succeeded++;
@@ -828,18 +1159,19 @@ if (finishedCount === allFiles.length) {
li.remove(); li.remove();
delete progressElements[file.uploadIndex]; delete progressElements[file.uploadIndex];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) { if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
const fileInput = document.getElementById("file"); const fi = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fi) fi.value = "";
progressContainer.innerHTML = ""; pc.innerHTML = "";
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); if (dropArea) setDropAreaDefault();
window.selectedFiles = [];
} }
}, 5000); }, 5000);
} }
@@ -849,7 +1181,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded; const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`); showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else { } else {
showToast(`${succeeded} file succeeded. Please check the list.`); showToast(`${succeeded} file(s) succeeded. Please check the list.`);
} }
}) })
.catch(error => { .catch(error => {
@@ -858,7 +1190,6 @@ if (finishedCount === allFiles.length) {
}) })
.finally(() => { .finally(() => {
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
}); });
} }
} }
@@ -867,9 +1198,17 @@ if (finishedCount === allFiles.length) {
Main initUpload: Sets up file input, drop area, and form submission. Main initUpload: Sets up file input, drop area, and form submission.
----------------------------------------------------- */ ----------------------------------------------------- */
function initUpload() { function initUpload() {
const fileInput = document.getElementById("file"); window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
const dropArea = document.getElementById("uploadDropArea"); window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
const uploadForm = document.getElementById("uploadFileForm"); const uploadForm = document.getElementById("uploadFileForm");
const dropArea = document.getElementById("uploadDropArea");
// Always (re)build the inner markup and wire the Choose button
setDropAreaDefault();
wireChooseButton();
const fileInput = document.getElementById("file");
// For file picker, remove directory attributes so only files can be chosen. // For file picker, remove directory attributes so only files can be chosen.
if (fileInput) { if (fileInput) {
@@ -879,80 +1218,79 @@ function initUpload() {
fileInput.setAttribute("multiple", ""); fileInput.setAttribute("multiple", "");
} }
setDropAreaDefault();
// Draganddrop events (for folder uploads) use original processing. // Draganddrop events (for folder uploads) use original processing.
if (dropArea) { if (dropArea && !dropArea.__uploadBound) {
dropArea.__uploadBound = true;
dropArea.classList.add("upload-drop-area"); dropArea.classList.add("upload-drop-area");
dropArea.addEventListener("dragover", function (e) { dropArea.addEventListener("dragover", function (e) {
e.preventDefault(); e.preventDefault();
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8"; dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
}); });
dropArea.addEventListener("dragleave", function (e) { dropArea.addEventListener("dragleave", function (e) {
e.preventDefault(); e.preventDefault();
dropArea.style.backgroundColor = ""; dropArea.style.backgroundColor = "";
}); });
dropArea.addEventListener("drop", function (e) { dropArea.addEventListener("drop", function (e) {
e.preventDefault(); e.preventDefault();
dropArea.style.backgroundColor = ""; dropArea.style.backgroundColor = "";
const dt = e.dataTransfer; const dt = e.dataTransfer || window.__pendingDropData || null;
if (dt.items && dt.items.length > 0) { window.__pendingDropData = null;
if (dt && dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => { getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) { if (files.length > 0) {
processFiles(files); processFiles(files);
} }
}); });
} else if (dt.files && dt.files.length > 0) { } else if (dt && dt.files && dt.files.length > 0) {
processFiles(dt.files); processFiles(dt.files);
} }
}); });
// Clicking drop area triggers file input.
dropArea.addEventListener("click", function () { // Only trigger file picker when clicking the *bare* drop area, not controls inside it
if (fileInput) fileInput.click(); dropArea.addEventListener("click", function (e) {
// If the click originated from the "Choose files" button or the file input itself,
// let their handlers deal with it.
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
return;
}
triggerFilePickerOnce();
}); });
} }
if (fileInput) { if (uploadForm && !uploadForm.__uploadSubmitBound) {
fileInput.addEventListener("change", async function () { uploadForm.__uploadSubmitBound = true;
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f);
} else {
// If still not ready (load error), fall back to your XHR path
processFiles(files);
}
} else {
processFiles(files);
}
});
}
if (uploadForm) {
uploadForm.addEventListener("submit", async function (e) { uploadForm.addEventListener("submit", async function (e) {
e.preventDefault(); e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
const files =
(Array.isArray(window.selectedFiles) && window.selectedFiles.length)
? window.selectedFiles
: (fileInput ? Array.from(fileInput.files || []) : []);
if (!files || !files.length) { if (!files || !files.length) {
showToast("No files selected."); showToast("No files selected.");
return; return;
} }
// Resumable path (only for picked files, not folder uploads) const hasResumableFiles =
const first = files[0]; useResumable &&
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath); resumableInstance &&
if (useResumable && !isFolderish) { Array.isArray(resumableInstance.files) &&
resumableInstance.files.length > 0;
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload(); if (!_resumableReady) await initResumableUpload();
if (resumableInstance) { if (resumableInstance) {
// ensure folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root"; resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
resumableInstance.upload(); resumableInstance.upload();
showToast("Resumable upload started..."); showToast("Resumable upload started...");
} else { } else {
// fallback
submitFiles(files); submitFiles(files);
} }
} else { } else {
@@ -964,6 +1302,7 @@ function initUpload() {
if (useResumable) { if (useResumable) {
initResumableUpload(); initResumableUpload();
} }
showResumableDraftBanner();
} }
export { initUpload }; export { initUpload };

View File

@@ -1,2 +1,2 @@
// generated by CI // generated by CI
window.APP_VERSION = 'v1.8.3'; window.APP_VERSION = 'v2.0.4';

146
public/portal-login.html Normal file
View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign in Client Portal</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="">
<meta name="color-scheme" content="light dark">
<!-- Favicons / assets -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<!-- CSS (reuse main app look) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Version stamp -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- Portal login JS -->
<script type="module" src="/js/portal-login.js?v={{APP_QVER}}"></script>
<style>
html, body {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
background: var(--pre-bg, #f4f4f7);
}
.portal-login-wrapper {
width: 100%;
max-width: 420px;
padding: 16px;
}
.portal-login-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 22px 18px;
background: #fff;
}
[data-theme="dark"] .portal-login-card {
background: #1f2933;
color: #e5e7eb;
}
.portal-login-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.portal-login-header img {
width: 32px;
height: 32px;
}
.portal-login-title {
font-weight: 600;
font-size: 1rem;
line-height: 1.2;
}
.portal-login-subtitle {
font-size: 0.8rem;
color: #6c757d;
}
[data-theme="dark"] .portal-login-subtitle {
color: #9ca3af;
}
#portalLoginError {
font-size: 0.85rem;
margin-bottom: 8px;
display: none;
}
#portalLoginError.show {
display: block;
}
.portal-login-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 22px 18px;
background: #fff;
border-top: 3px solid var(--filr-accent-500, #0b5ed7);
}
</style>
</head>
<body data-theme="light">
<div class="portal-login-wrapper">
<div class="portal-login-card">
<div class="portal-login-header">
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
<div>
<div id="portalLoginTitle" class="portal-login-title">
Sign in to Client Portal
</div>
<div id="portalLoginSubtitle" class="portal-login-subtitle">
to access this client portal
</div>
</div>
</div>
<div id="portalLoginError" class="alert alert-danger"></div>
<form id="portalLoginForm" novalidate>
<div class="form-group">
<label for="portalLoginUser">Username or email</label>
<input type="text"
class="form-control form-control-sm"
id="portalLoginUser"
autocomplete="username"
required>
</div>
<div class="form-group">
<label for="portalLoginPass">Password</label>
<input type="password"
class="form-control form-control-sm"
id="portalLoginPass"
autocomplete="current-password"
required>
</div>
<button type="submit"
id="portalLoginSubmit"
class="btn btn-primary btn-sm btn-block">
Sign in
</button>
</form>
<small id="portalLoginHint"
class="text-muted d-block mt-2"
style="font-size:0.75rem;">
Youll be sent back to the portal automatically after signing in.
</small>
<small id="portalLoginFooter"
class="text-muted d-block mt-1"
style="font-size:0.7rem; display:none;">
</small>
</div>
</div>
</body>
</html>

362
public/portal.html Normal file
View File

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="en">
<style id="pretheme-css">
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
</style>
<head>
<style>
:root {
--portal-accent: #0b5ed7;
}
.portal-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.portal-card {
max-width: 640px;
width: 100%;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 20px 16px;
}
.portal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.portal-logo {
display: flex;
align-items: center;
gap: 8px;
}
.portal-logo img {
width: 32px;
height: 32px;
}
.portal-dropzone {
border: 2px dashed rgba(0,0,0,0.2);
border-radius: 10px;
padding: 18px;
text-align: center;
margin-top: 10px;
transition: background 0.15s, border-color 0.15s;
cursor: pointer;
}
.portal-dropzone.dragover {
border-color: var(--portal-accent);
background: rgba(11,94,215,0.06);
}
/* Files list container (scrollable) */
.portal-files-list {
margin-top: 14px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
/* NEW: grid-style gallery inside the list */
.portal-files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: minmax(48px, auto);
gap: 8px;
}
.portal-file-card {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: rgba(0,0,0,0.01);
font-size: 0.85rem;
}
.portal-file-card:hover {
background: rgba(0,0,0,0.04);
}
.portal-file-card-icon {
flex: 0 0 auto;
width: 34px;
height: 34px;
border-radius: 10px;
border: 2px solid var(--portal-accent, #0b5ed7);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
}
.portal-file-card-main {
flex: 1;
min-width: 0;
}
.portal-file-card-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portal-file-card-meta {
font-size: 0.78rem;
}
.portal-file-card-actions {
flex: 0 0 auto;
margin-left: auto;
}
.portal-file-card-download {
font-size: 0.78rem;
text-decoration: none;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.16);
background: transparent;
white-space: nowrap;
}
.portal-file-card-download:hover {
background: var(--portal-accent, #0b5ed7);
color: #fff;
border-color: var(--portal-accent, #0b5ed7);
text-decoration: none;
}
.portal-status {
margin-top: 8px;
font-size: 0.85rem;
}
#customToast {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 4000;
display: none;
}
#customToast.show {
opacity: 1;
transform: translateY(0);
}
/* (Optional) keep old row style around if anything else uses it */
.portal-file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 0.9rem;
}
.portal-file-row:last-child {
border-bottom: none;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Client Portal FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<style id="pretheme-css">
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
</style>
<!-- Favicons / assets -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<meta name="csrf-token" content="">
<meta name="color-scheme" content="light dark">
<!-- CSS (reuse main app CSS for look) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Version stamp -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- Portal entry -->
<script type="module" src="/js/portal.js?v={{APP_QVER}}"></script>
<style>
.portal-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.portal-card {
max-width: min(960px, 100%);
width: 100%;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 20px 16px;
}
.portal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.portal-logo {
display: flex;
align-items: center;
gap: 8px;
}
.portal-logo img {
width: 32px;
height: 32px;
}
.portal-dropzone {
border: 2px dashed rgba(0,0,0,0.2);
border-radius: 10px;
padding: 18px;
text-align: center;
margin-top: 10px;
transition: background 0.15s, border-color 0.15s;
cursor: pointer;
}
.portal-dropzone.dragover {
border-color: #0b5ed7;
background: rgba(11,94,215,0.06);
}
.portal-files-list {
margin-top: 14px;
max-height: 260px;
overflow-y: auto;
}
.portal-file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 0.9rem;
}
.portal-file-row:last-child {
border-bottom: none;
}
.portal-status {
margin-top: 8px;
font-size: 0.85rem;
}
#customToast {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 4000;
display: none;
}
#customToast.show {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div id="portalRoot" class="portal-wrapper">
<div class="portal-card">
<div class="portal-header">
<div class="portal-logo">
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
<div>
<div id="portalBrandHeading" style="font-weight:600; font-size:1rem;">Client Portal</div>
<div id="portalSubtitle" class="text-muted" style="font-size:0.8rem;"></div>
</div>
</div>
<small id="portalUserLabel" class="text-muted"></small>
</div>
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
Please fill in your information before uploading files.
</p>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormName">Name</label>
<input type="text" id="portalFormName" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormEmail">Email</label>
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormReference">Reference / Case / Order #</label>
<input type="text" id="portalFormReference" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:8px;">
<label for="portalFormNotes">Notes</label>
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
</div>
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
Continue
</button>
</div>
<div id="portalUploadSection">
<div id="portalDropzone" class="portal-dropzone">
<div><strong>Drop files here</strong> or click to browse.</div>
<div style="font-size:0.8rem;" class="text-muted">
Files will be uploaded to this portal only.
</div>
</div>
<input type="file" id="portalFileInput" multiple style="display:none;">
<div id="portalStatus" class="portal-status text-muted"></div>
</div>
<div id="portalFilesSection" style="margin-top:12px; display:none;">
<div class="d-flex justify-content-between align-items-center">
<strong style="font-size:0.95rem;">Files in this portal</strong>
<button type="button" id="portalRefreshBtn" class="btn btn-sm btn-outline-secondary">
Refresh
</button>
</div>
<div id="portalFilesList" class="portal-files-list"></div>
</div>
<div id="portalFooter" class="text-muted"
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
</div>
</div>
<div id="customToast"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 666 KiB

BIN
resources/portal-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

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

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# === Update FileRise to v2.0.2 (safe rsync) ===
set -Eeuo pipefail
VER="v2.0.2"
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
# 0) quick backup of critical bits (include Pro/demo stuff too)
stamp="$(date +%F-%H%M)"
mkdir -p /root/backups
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
public/.htaccess \
config \
users \
uploads \
metadata \
filerise-bundles \
filerise-config \
filerise-site || 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
# - DO NOT touch filerise-site / bundles / demo config
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
--exclude='users/***' \
--exclude='metadata/***' \
--exclude='filerise-bundles/***' \
--exclude='filerise-config/***' \
--exclude='filerise-site/***' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) 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) Force demo mode ON in config/config.php
CFG_FILE="$WEBROOT/config/config.php"
if [[ -f "$CFG_FILE" ]]; then
# Make a one-time backup of config.php before editing
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
# Flip FR_DEMO_MODE to true if it exists as false
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
fi
# 7) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, and demo site preserved."

179
src/cli/zip_worker.php Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/FileModel.php';
$token = $argv[1] ?? '';
$token = preg_replace('/[^a-f0-9]/','',$token);
if ($token === '') { fwrite(STDERR, "No token\n"); exit(1); }
$root = rtrim((string)META_DIR, '/\\') . '/ziptmp';
$tokDir = $root . '/.tokens';
$logDir = $root . '/.logs';
@mkdir($tokDir, 0775, true);
@mkdir($logDir, 0775, true);
$tokFile = $tokDir . '/' . $token . '.json';
$logFile = $logDir . '/WORKER-' . $token . '.log';
file_put_contents($logFile, "[".date('c')."] worker start token={$token}\n", FILE_APPEND);
// Keep libzip temp files on same FS as final zip (prevents cross-device rename failures)
@mkdir($root, 0775, true);
@putenv('TMPDIR='.$root);
@ini_set('sys_temp_dir', $root);
// Small janitor: purge old tokens/logs (> 6h)
$now = time();
foreach (glob($tokDir.'/*.json') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
foreach (glob($logDir.'/WORKER-*.log') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
// Helpers to read/write the token file safely
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
$save = function() use (&$job, $tokFile) {
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
@clearstatcache(true, $tokFile);
};
$touchPhase = function(string $phase) use (&$job, $save) {
$job['phase'] = $phase;
$save();
};
// Init timing
if (empty($job['startedAt'])) {
$job['startedAt'] = time();
}
$job['status'] = 'working';
$job['error'] = null;
$save();
// Build the list of files to zip using the model (same validation FileRise uses)
try {
// Reuse FileModels validation by calling it but not keeping the zip; well enumerate sizes here.
$folder = (string)($job['folder'] ?? 'root');
$names = (array)($job['files'] ?? []);
// Resolve folder path similarly to createZipArchive
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
throw new RuntimeException('Uploads directory not configured correctly.');
}
if (strtolower($folder) === 'root' || $folder === "") {
$folderPathReal = $baseDir;
} else {
if (strpos($folder, '..') !== false) throw new RuntimeException('Invalid folder name.');
$parts = explode('/', trim($folder, "/\\ "));
foreach ($parts as $part) {
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
throw new RuntimeException('Invalid folder name.');
}
}
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
throw new RuntimeException('Folder not found.');
}
}
// Collect files (only regular files)
$filesToZip = [];
foreach ($names as $nm) {
$bn = basename(trim((string)$nm));
if (!preg_match(REGEX_FILE_NAME, $bn)) continue;
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
if (is_file($fp)) $filesToZip[] = $fp;
}
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
// Totals for progress
$filesTotal = count($filesToZip);
$bytesTotal = 0;
foreach ($filesToZip as $fp) {
$sz = @filesize($fp);
if ($sz !== false) $bytesTotal += (int)$sz;
}
$job['filesTotal'] = $filesTotal;
$job['bytesTotal'] = $bytesTotal;
$job['filesDone'] = 0;
$job['bytesDone'] = 0;
$job['pct'] = 0;
$job['current'] = null;
$job['phase'] = 'zipping';
$save();
// Create final zip path in META_DIR/ziptmp
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Could not create zip archive.');
}
// Enumerate files; report up to 98%
$bytesDone = 0;
$filesDone = 0;
foreach ($filesToZip as $fp) {
$bn = basename($fp);
$zip->addFile($fp, $bn);
$filesDone++;
$sz = @filesize($fp);
if ($sz !== false) $bytesDone += (int)$sz;
$job['filesDone'] = $filesDone;
$job['bytesDone'] = $bytesDone;
$job['current'] = $bn;
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
if ($pct < 0) $pct = 0;
if ($pct > 98) $pct = 98;
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
$save();
}
// Finalizing (this is where libzip writes & renames)
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
$job['phase'] = 'finalizing';
$job['finalizeAt'] = time();
// Publish selected totals for a truthful UI during finalizing,
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
$job['selectedFiles'] = $filesTotal;
$job['selectedBytes'] = $bytesTotal;
$job['filesDone'] = null;
$job['bytesDone'] = null;
$job['current'] = null;
$save();
// ---- finalize the zip on disk ----
$ok = $zip->close();
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
if (!$ok || !is_file($zipPath)) {
$job['status'] = 'error';
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['status'] = 'done';
$job['zipPath'] = $zipPath;
$job['pct'] = 100;
$job['phase'] = 'finalized';
$save();
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
} catch (Throwable $e) {
$job['status'] = 'error';
$job['error'] = 'Worker exception: '.$e->getMessage();
$save();
file_put_contents($logFile, "[".date('c')."] exception: ".$e->getMessage()."\n", FILE_APPEND);
}

View File

@@ -0,0 +1,166 @@
<?php
// src/controllers/AclAdminController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
class AclAdminController
{
public function getUserGrants(string $user): array
{
if (!preg_match(REGEX_USER, $user)) {
throw new InvalidArgumentException('Invalid user');
}
$folders = [];
try {
$rows = FolderModel::getFolderList();
if (is_array($rows)) {
foreach ($rows as $r) {
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
if ($f !== '') $folders[$f] = true;
}
}
} catch (\Throwable $e) {
// ignore, fall back to ACL file
}
if (empty($folders)) {
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
foreach ($data['folders'] as $name => $_) {
$folders[$name] = true;
}
}
}
}
$folderList = array_keys($folders);
if (!in_array('root', $folderList, true)) {
array_unshift($folderList, 'root');
}
$has = function(array $arr, string $u): bool {
foreach ($arr as $x) {
if (strcasecmp((string)$x, $u) === 0) return true;
}
return false;
};
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicitAll($f);
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
if (
$canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'], $user) || $has($rec['edit'], $user) || $has($rec['rename'], $user)
|| $has($rec['copy'], $user) || $has($rec['move'], $user) || $has($rec['delete'], $user)
|| $has($rec['extract'], $user) || $has($rec['share_file'], $user) || $has($rec['share_folder'], $user)
) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare,
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'], $user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'], $user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'], $user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'], $user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'], $user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'], $user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'], $user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'], $user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'], $user),
];
}
}
return $out;
}
public function saveUserGrantsPayload(array $payload): array
{
$normalizeCaps = function (array $row): array {
$bool = function ($v) {
return !empty($v) && $v !== 'false' && $v !== 0;
};
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) {
$out[$kk] = $bool($row[$kk] ?? false);
}
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
return $out;
};
$sanitizeGrantsMap = function (array $grants) use ($normalizeCaps): array {
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$out[$folder] = $normalizeCaps($caps);
}
return $out;
};
$validUser = function (string $u): bool {
return ($u !== '' && preg_match(REGEX_USER, $u));
};
// Single-user mode
if (isset($payload['user'], $payload['grants']) && is_array($payload['grants'])) {
$user = trim((string)$payload['user']);
if (!$validUser($user)) {
throw new InvalidArgumentException('Invalid user');
}
$grants = $sanitizeGrantsMap($payload['grants']);
return ACL::applyUserGrantsAtomic($user, $grants);
}
// Batch mode
if (isset($payload['changes']) && is_array($payload['changes'])) {
$updated = [];
foreach ($payload['changes'] as $chg) {
if (!is_array($chg)) continue;
$user = trim((string)($chg['user'] ?? ''));
$gr = $chg['grants'] ?? null;
if (!$validUser($user) || !is_array($gr)) continue;
try {
$res = ACL::applyUserGrantsAtomic($user, $sanitizeGrantsMap($gr));
$updated[$user] = $res['updated'] ?? [];
} catch (\Throwable $e) {
$updated[$user] = ['error' => $e->getMessage()];
}
}
return ['ok' => true, 'updated' => $updated];
}
throw new InvalidArgumentException('Invalid payload: expected {user,grants} or {changes:[{user,grants}]}');
}
}

View File

@@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1);
// src/controllers/AdminController.php // src/controllers/AdminController.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
@@ -6,6 +7,89 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class AdminController class AdminController
{ {
/** Enforce authentication (401). */
public static function requireAuth(): void
{
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/** Enforce admin (401). */
public static function requireAdmin(): void
{
self::requireAuth();
// Prefer the session flag
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
// Fallback: check the users role in storage (e.g., users.txt/DB)
if (!$isAdmin) {
$u = $_SESSION['username'] ?? '';
if ($u) {
try {
// UserModel::getUserRole($u) should return '1' for admins
$isAdmin = (UserModel::getUserRole($u) === '1');
if ($isAdmin) {
// Normalize session so downstream ACL checks see admin
$_SESSION['isAdmin'] = true;
}
} catch (\Throwable $e) {
// ignore and continue to deny
}
}
}
if (!$isAdmin) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Admin privileges required.']);
exit;
}
}
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = [];
foreach ($headers as $k => $v) {
$out[strtolower($k)] = $v;
}
// Fallbacks from $_SERVER if needed
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$h = strtolower(str_replace('_', '-', substr($k, 5)));
if (!isset($out[$h])) $out[$h] = $v;
}
}
return $out;
}
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
public static function requireCsrf(): void
{
$h = self::headersLower();
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
}
/** Read JSON body (empty array if not valid). */
private static function readJson(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
public function getConfig(): void public function getConfig(): void
{ {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -24,7 +108,7 @@ class AdminController
? (bool) ONLYOFFICE_ENABLED ? (bool) ONLYOFFICE_ENABLED
: (bool) ($ooCfg['enabled'] ?? false); : (bool) ($ooCfg['enabled'] ?? false);
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '' $effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
? (string) ONLYOFFICE_DOCS_ORIGIN ? (string) ONLYOFFICE_DOCS_ORIGIN
: (string) ($ooCfg['docsOrigin'] ?? ''); : (string) ($ooCfg['docsOrigin'] ?? '');
@@ -34,6 +118,33 @@ class AdminController
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? ''); $publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
// ---- Pro / license info (all guarded for clean core installs) ----
$licenseString = null;
if (defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
$json = @file_get_contents(PRO_LICENSE_FILE);
if ($json !== false) {
$decoded = json_decode($json, true);
if (is_array($decoded) && !empty($decoded['license'])) {
$licenseString = (string) $decoded['license'];
}
}
}
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
// FR_PRO_INFO is only defined when bootstrap_pro.php has run; guard it
$proPayload = [];
if (defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)) {
$p = FR_PRO_INFO['payload'] ?? null;
if (is_array($p)) {
$proPayload = $p;
}
}
$proType = $proPayload['type'] ?? null;
$proEmail = $proPayload['email'] ?? null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
// Whitelisted public subset only (+ ONLYOFFICE enabled flag) // Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [ $public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'), 'header_title' => (string)($config['header_title'] ?? 'FileRise'),
@@ -54,6 +165,19 @@ class AdminController
// Public only needs to know if its on; no secrets/origins here. // Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled, 'enabled' => $effEnabled,
], ],
'branding' => [
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
],
'pro' => [
'active' => $proActive,
'type' => $proType,
'email' => $proEmail,
'version' => $proVersion,
'license' => $licenseString,
],
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
]; ];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']); $isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
@@ -78,9 +202,11 @@ class AdminController
defined('ONLYOFFICE_ENABLED') defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_JWT_SECRET') || defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_PUBLIC_ORIGIN')
), ),
], ],
]; ];
header('Cache-Control: no-store'); // dont cache admin config header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return; return;
@@ -92,6 +218,492 @@ class AdminController
return; return;
} }
public function setLicense(): void
{
// Always respond JSON
header('Content-Type: application/json; charset=utf-8');
try {
// Same guards as other admin endpoints
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '{}', true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
$license = isset($data['license']) ? trim((string)$data['license']) : '';
// Store license + updatedAt in JSON file
if (!defined('PRO_LICENSE_FILE')) {
// Fallback if constant not defined for some reason
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
}
$payload = [
'license' => $license,
'updatedAt' => gmdate('c'),
];
$dir = dirname(PRO_LICENSE_FILE);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
return;
}
$json = json_encode($payload, JSON_PRETTY_PRINT);
if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to write license file']);
return;
}
echo json_encode(['success' => true]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}
}
public function getProPortals(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
// ProPortals is implemented in the Pro bundle and handles JSON storage.
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$portals = $store->listPortals();
return $portals;
}
/**
* @param array $portalsPayload Raw "portals" array from JSON body
*/
public function saveProPortals(array $portalsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
if (!is_array($portalsPayload)) {
throw new InvalidArgumentException('Invalid portals format.');
}
// Minimal normalization; deeper validation can live inside ProPortals
$data = ['portals' => []];
foreach ($portalsPayload as $slug => $info) {
$slug = trim((string)$slug);
if ($slug === '') {
continue;
}
if (!is_array($info)) {
$info = [];
}
$label = trim((string)($info['label'] ?? $slug));
$folder = trim((string)($info['folder'] ?? ''));
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
$uploadOnly = !empty($info['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $info)
? !empty($info['allowDownload'])
: true;
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
// Optional branding + form behavior
$title = trim((string)($info['title'] ?? ''));
$introText = trim((string)($info['introText'] ?? ''));
$requireForm = !empty($info['requireForm']);
$brandColor = trim((string)($info['brandColor'] ?? ''));
$footerText = trim((string)($info['footerText'] ?? ''));
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
? $info['formDefaults']
: [];
// Normalize defaults for known keys
$formDefaults = [
'name' => trim((string)($formDefaults['name'] ?? '')),
'email' => trim((string)($formDefaults['email'] ?? '')),
'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
? $info['formRequired']
: [];
$formRequired = [
'name' => !empty($formRequired['name']),
'email' => !empty($formRequired['email']),
'reference' => !empty($formRequired['reference']),
'notes' => !empty($formRequired['notes']),
];
if ($folder === '') {
continue;
}
$data['portals'][$slug] = [
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
// NEW
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
];
}
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$ok = $store->savePortals($data);
if (!$ok) {
throw new RuntimeException('Could not write portals.json');
}
}
public function getProGroups(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
$groups = $store->listGroups();
return $groups;
}
/**
* @param array $groupsPayload Raw "groups" array from JSON body
*/
public function saveProGroups(array $groupsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
// Normalize / validate the payload into the canonical structure
if (!is_array($groupsPayload)) {
throw new InvalidArgumentException('Invalid groups format.');
}
$data = ['groups' => []];
foreach ($groupsPayload as $name => $info) {
$name = trim((string)$name);
if ($name === '') {
continue;
}
$label = isset($info['label']) ? trim((string)$info['label']) : $name;
$members = isset($info['members']) && is_array($info['members']) ? $info['members'] : [];
$grants = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : [];
$data['groups'][$name] = [
'name' => $name,
'label' => $label,
'members' => array_values(array_unique(array_map('strval', $members))),
'grants' => $grants,
];
}
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
if (!$store->save($data)) {
throw new RuntimeException('Could not write groups.json');
}
}
public function installProBundle(): void
{
header('Content-Type: application/json; charset=utf-8');
try {
// Guard rails: method + auth + CSRF
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
// Ensure ZipArchive is available
if (!class_exists('\\ZipArchive')) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
return;
}
// Basic upload validation
if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
return;
}
$f = $_FILES['bundle'];
if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
$msg = 'Upload error.';
switch ($f['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$msg = 'Uploaded file exceeds size limit.';
break;
case UPLOAD_ERR_PARTIAL:
$msg = 'Uploaded file was only partially received.';
break;
case UPLOAD_ERR_NO_FILE:
$msg = 'No file was uploaded.';
break;
default:
$msg = 'Upload failed with error code ' . (int)$f['error'];
break;
}
http_response_code(400);
echo json_encode(['success' => false, 'error' => $msg]);
return;
}
$tmpName = $f['tmp_name'] ?? '';
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
return;
}
// Guard against unexpectedly large bundles (e.g., >100MB)
$size = isset($f['size']) ? (int)$f['size'] : 0;
if ($size <= 0 || $size > 100 * 1024 * 1024) {
http_response_code(413);
echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
return;
}
// Optional: require .zip extension by name (best-effort)
$origName = (string)($f['name'] ?? '');
if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
return;
}
// Prepare temp working dir
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
if (!@mkdir($workDir, 0700, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
return;
}
$zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
if (!@move_uploaded_file($tmpName, $zipPath)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
return;
}
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
return;
}
$installed = [
'src' => [],
'docs' => [],
];
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
// Where Pro bundle code lives (defaults to USERS_DIR . '/pro')
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
: (rtrim(USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro');
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
$proDocsDir = $bundleRoot;
if (!is_dir($proDocsDir)) {
@mkdir($proDocsDir, 0755, true);
}
$allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
// Iterate entries and selectively extract/copy expected files only
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
// Normalise and guard
$name = ltrim($name, "/\\");
if ($name === '' || substr($name, -1) === '/') {
continue; // skip directories
}
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
continue; // path traversal guard
}
// Ignore macOS Finder junk: __MACOSX and "._" resource forks
$base = basename($name);
if (
str_starts_with($name, '__MACOSX/') ||
str_contains($name, '/__MACOSX/') ||
str_starts_with($base, '._')
) {
continue;
}
$targetPath = null;
$category = null;
if (in_array($name, $allowedTopLevel, true)) {
// Docs → bundle dir (under /users/pro)
$targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
$category = 'docs';
} elseif (strpos($name, 'src/pro/') === 0) {
// e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
$relative = substr($name, strlen('src/pro/'));
if ($relative === '' || substr($relative, -1) === '/') {
continue;
}
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
$category = 'src';
} else {
// Skip anything outside these prefixes
continue;
}
if (!$targetPath || !$category) {
continue;
}
// Track whether we're overwriting an existing file (for reporting only)
$wasExisting = is_file($targetPath);
// Read from ZIP entry
$stream = $zip->getStream($name);
if (!$stream) {
continue;
}
$dir = dirname($targetPath);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
fclose($stream);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
return;
}
$data = stream_get_contents($stream);
fclose($stream);
if ($data === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
return;
}
// Always overwrite target file on install/upgrade
if (@file_put_contents($targetPath, $data) === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
return;
}
@chmod($targetPath, 0644);
// Track what we installed (and whether it was overwritten)
if (!isset($installed[$category])) {
$installed[$category] = [];
}
$installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
}
$zip->close();
// Best-effort cleanup; ignore failures
@unlink($zipPath);
@rmdir($workDir);
// Reflect current Pro status in response if bootstrap was loaded
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
? (FR_PRO_INFO['payload'] ?? null)
: null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
echo json_encode([
'success' => true,
'message' => 'Pro bundle installed.',
'installed' => $installed,
'proActive' => (bool)$proActive,
'proVersion' => $proVersion,
'proPayload' => $proPayload,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception during bundle install: ' . $e->getMessage(),
]);
}
}
public function updateConfig(): void public function updateConfig(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -149,6 +761,11 @@ class AdminController
'clientSecret'=> '', 'clientSecret'=> '',
'redirectUri' => '' 'redirectUri' => ''
], ],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
]; ];
// header_title (cap length and strip control chars) // header_title (cap length and strip control chars)
@@ -251,6 +868,7 @@ class AdminController
} }
} }
// —– persist merged config —– // —– persist merged config —–
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ---- // ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
$ooLockedByPhp = ( $ooLockedByPhp = (
@@ -286,6 +904,22 @@ class AdminController
$merged['onlyoffice'] = $oo; $merged['onlyoffice'] = $oo;
} }
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
if (isset($data['branding']) && is_array($data['branding'])) {
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
$merged['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
}
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
if (array_key_exists($key, $data['branding'])) {
$merged['branding'][$key] = (string)$data['branding'][$key];
}
}
}
$result = AdminModel::updateConfig($merged); $result = AdminModel::updateConfig($merged);
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(500); http_response_code(500);

View File

@@ -58,11 +58,25 @@ class AuthController
} }
if ($oidcAction) { if ($oidcAction) {
$cfg = AdminModel::getConfig(); $cfg = AdminModel::getConfig();
$clientId = $cfg['oidc']['clientId'] ?? null;
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
// When configured as a public client (no secret), pass null, not an empty string.
if ($clientSecret === '') { $clientSecret = null; }
$oidc = new OpenIDConnectClient( $oidc = new OpenIDConnectClient(
$cfg['oidc']['providerUrl'], $cfg['oidc']['providerUrl'],
$cfg['oidc']['clientId'], $clientId ?: null,
$cfg['oidc']['clientSecret'] $clientSecret
); );
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
if (method_exists($oidc, 'setCodeChallengeMethod')) {
$oidc->setCodeChallengeMethod('S256');
}
// client_secret_post with Authelia using config.php
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
}
$oidc->setRedirectURL($cfg['oidc']['redirectUri']); $oidc->setRedirectURL($cfg['oidc']['redirectUri']);
$oidc->addScope(['openid','profile','email']); $oidc->addScope(['openid','profile','email']);

View File

@@ -190,6 +190,59 @@ class FileController
return $ok ? null : "Forbidden: folder scope violation."; return $ok ? null : "Forbidden: folder scope violation.";
} }
private function spawnZipWorker(string $token, string $tokFile, string $logDir): array
{
$worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php');
if (!$worker || !is_file($worker)) {
return ['ok'=>false, 'error'=>'zip_worker.php not found'];
}
// Find a PHP CLI binary that actually works
$candidates = array_values(array_filter([
PHP_BINARY ?: null,
'/usr/local/bin/php',
'/usr/bin/php',
'/bin/php'
]));
$php = null;
foreach ($candidates as $bin) {
if (!$bin) continue;
$rc = 1;
@exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc);
if ($rc === 0) { $php = $bin; break; }
}
if (!$php) {
return ['ok'=>false, 'error'=>'No working php CLI found'];
}
$logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log';
// Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process.
$tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp';
@mkdir($tmpDir, 0775, true);
// Build one sh -c string so env + nohup + echo $! are in the same shell
$cmdStr =
'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' .
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) .
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
$pid = is_string($pid) ? (int)trim($pid) : 0;
// Persist spawn metadata into token (best-effort)
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
$job['spawn'] = [
'ts' => time(),
'php' => $php,
'pid' => $pid,
'log' => $logFile
];
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID'];
}
// --- small helpers --- // --- small helpers ---
private function _jsonStart(): void { private function _jsonStart(): void {
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
@@ -590,25 +643,137 @@ public function deleteFiles()
} finally { $this->_jsonEnd(); } } finally { $this->_jsonEnd(); }
} }
/**
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
*
* @param string $fullPath Absolute filesystem path
* @param string $downloadName Name shown in Content-Disposition
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
* @param bool $inline true => inline, false => attachment
*/
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
{
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'File not found']);
exit;
}
$size = (int)@filesize($fullPath);
$start = 0;
$end = $size > 0 ? $size - 1 : 0;
if ($size < 0) {
$size = 0;
$end = 0;
}
// Close session + disable output buffering for streaming
if (session_status() === PHP_SESSION_ACTIVE) {
@session_write_close();
}
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', 'off');
while (ob_get_level() > 0) {
@ob_end_clean();
}
$disposition = $inline ? 'inline' : 'attachment';
$mime = $mimeType ?: 'application/octet-stream';
header('X-Content-Type-Options: nosniff');
header('Accept-Ranges: bytes');
header("Content-Type: {$mime}");
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
// Handle HTTP Range header (single range)
$length = $size;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') {
$start = (int)$m[1];
}
if ($m[2] !== '') {
$end = (int)$m[2];
}
// clamp to file size
if ($start < 0) $start = 0;
if ($end < $start) $end = $start;
if ($end >= $size) $end = $size - 1;
$length = $end - $start + 1;
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
header("Content-Length: {$length}");
} else {
// no range => full file
http_response_code(200);
if ($size > 0) {
header("Content-Length: {$size}");
}
}
$fp = @fopen($fullPath, 'rb');
if ($fp === false) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Unable to open file.']);
exit;
}
if ($start > 0) {
@fseek($fp, $start);
}
$bytesToSend = $length;
$chunkSize = 8192;
while ($bytesToSend > 0 && !feof($fp)) {
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
$buffer = fread($fp, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$bytesToSend -= strlen($buffer);
if (connection_aborted()) {
break;
}
}
fclose($fp);
exit;
}
public function downloadFile() public function downloadFile()
{ {
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
header('Content-Type: application/json'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
$file = isset($_GET['file']) ? basename($_GET['file']) : ''; $file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; $folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
if (!preg_match(REGEX_FILE_NAME, $file)) { if (!preg_match(REGEX_FILE_NAME, $file)) {
http_response_code(400); http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid file name."]); echo json_encode(["error" => "Invalid file name."]);
exit; exit;
} }
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400); http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(["error" => "Invalid folder name."]);
exit; exit;
} }
@@ -628,6 +793,7 @@ public function deleteFiles()
if (!$fullView && !$ownGrant) { if (!$fullView && !$ownGrant) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: no view access to this folder."]); echo json_encode(["error" => "Forbidden: no view access to this folder."]);
exit; exit;
} }
@@ -637,6 +803,7 @@ public function deleteFiles()
$meta = $this->loadFolderMetadata($folder); $meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit; exit;
} }
@@ -644,33 +811,118 @@ public function deleteFiles()
$downloadInfo = FileModel::getDownloadInfo($folder, $file); $downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) { if (isset($downloadInfo['error'])) {
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400); http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => $downloadInfo['error']]); echo json_encode(["error" => $downloadInfo['error']]);
exit; exit;
} }
$realFilePath = $downloadInfo['filePath']; $realFilePath = $downloadInfo['filePath'];
$mimeType = $downloadInfo['mimeType']; $mimeType = $downloadInfo['mimeType'];
header("Content-Type: " . $mimeType);
// Decide inline vs attachment:
// - if ?inline=1 => always inline (used by filePreview.js)
// - else keep your old behavior: images inline, everything else attachment
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico']; $inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
if (in_array($ext, $inlineImageTypes, true)) {
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); $inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
} else {
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); // Stream with proper Range support for video/audio seeking
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
} }
header('Content-Length: ' . filesize($realFilePath));
readfile($realFilePath); public function zipStatus()
exit; {
if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; }
$username = $_SESSION['username'] ?? '';
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; }
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; }
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; }
$ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']);
$out = [
'status' => $job['status'] ?? 'unknown',
'error' => $job['error'] ?? null,
'ready' => $ready,
// progress (if present)
'pct' => $job['pct'] ?? null,
'filesDone' => $job['filesDone'] ?? null,
'filesTotal' => $job['filesTotal'] ?? null,
'bytesDone' => $job['bytesDone'] ?? null,
'bytesTotal' => $job['bytesTotal'] ?? null,
'current' => $job['current'] ?? null,
'phase' => $job['phase'] ?? null,
// timing (always include for UI)
'startedAt' => $job['startedAt'] ?? null,
'finalizeAt' => $job['finalizeAt'] ?? null,
];
if ($ready) {
$out['size'] = @filesize($job['zipPath']) ?: null;
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
}
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
echo json_encode($out);
}
public function downloadZipFile()
{
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; }
$username = $_SESSION['username'] ?? '';
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; }
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; }
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
@unlink($tokFile); // one-shot token
if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; }
$zip = (string)($job['zipPath'] ?? '');
$zipReal = realpath($zip);
$root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp');
if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; }
@session_write_close();
@set_time_limit(0);
@ignore_user_abort(true);
if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1');
@ini_set('zlib.output_compression','0');
@ini_set('output_buffering','off');
while (ob_get_level()>0) @ob_end_clean();
@clearstatcache(true, $zipReal);
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip';
if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip';
$size = (int)@filesize($zipReal);
header('X-Accel-Buffering: no');
header('X-Content-Type-Options: nosniff');
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="'.$name.'"');
if ($size>0) header('Content-Length: '.$size);
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($zipReal);
@unlink($zipReal);
} }
public function downloadZip() public function downloadZip()
{ {
$this->_jsonStart();
try { try {
if (!$this->_checkCsrf()) return; if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; }
if (!$this->_requireAuth()) return; if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); return; }
$data = $this->_readJsonBody(); $data = $this->_readJsonBody();
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
@@ -698,9 +950,7 @@ public function deleteFiles()
|| $this->ownsFolderOrAncestor($folder, $username, $perms); || $this->ownsFolderOrAncestor($folder, $username, $perms);
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); $ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
if (!$fullView && !$ownOnly) { if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; }
$this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return;
}
// If own-only, ensure all files are owned by the user // If own-only, ensure all files are owned by the user
if ($ownOnly) { if ($ownOnly) {
@@ -713,29 +963,82 @@ public function deleteFiles()
} }
} }
$result = FileModel::createZipArchive($folder, $files); $root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
if (isset($result['error'])) { $tokDir = $root . DIRECTORY_SEPARATOR . '.tokens';
$this->_jsonOut(["error" => $result['error']], 400); return; $logDir = $root . DIRECTORY_SEPARATOR . '.logs';
if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true);
if (!is_dir($logDir)) @mkdir($logDir, 0700, true);
@chmod($tokDir, 0700);
@chmod($logDir, 0700);
if (!is_dir($tokDir) || !is_writable($tokDir)) {
$this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return;
} }
$zipPath = $result['zipPath'] ?? null; // Light janitor: purge old tokens/logs > 6h (best-effort)
if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; } $now = time();
foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) {
if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); }
}
foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) {
if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); }
}
// switch to file streaming // Per-user and global caps (simple anti-DoS)
header_remove('Content-Type'); $perUserCap = 2; // tweak if desired
header('Content-Type: application/zip'); $globalCap = 8; // tweak if desired
header('Content-Disposition: attachment; filename="files.zip"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($zipPath); $tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: [];
@unlink($zipPath); $mine = 0; $all = 0;
exit; foreach ($tokens as $tf) {
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
$st = $job['status'] ?? 'unknown';
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
$all++;
if (($job['user'] ?? '') === $username) $mine++;
}
}
if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; }
if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; }
// Create job token
$token = bin2hex(random_bytes(16));
$tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json';
$job = [
'user' => $username,
'folder' => $folder,
'files' => array_values($files),
'status' => 'queued',
'ctime' => time(),
'startedAt' => null,
'finalizeAt' => null,
'zipPath' => null,
'error' => null
];
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
$this->_jsonOut(["error"=>"Failed to create zip job."],500); return;
}
// Robust spawn (detect php CLI, log, record PID)
$spawn = $this->spawnZipWorker($token, $tokFile, $logDir);
if (!$spawn['ok']) {
$job['status'] = 'error';
$job['error'] = 'Spawn failed: '.$spawn['error'];
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
$this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500);
return;
}
$this->_jsonOut([
'ok' => true,
'token' => $token,
'status' => 'queued',
'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token),
'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token)
]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500); $this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
} finally { $this->_jsonEnd(); } }
} }
public function extractZip() public function extractZip()

File diff suppressed because it is too large Load Diff

View File

@@ -44,9 +44,6 @@ class MediaController
$f = trim((string)$f); $f = trim((string)$f);
return ($f==='' || strtolower($f)==='root') ? 'root' : $f; return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
} }
private function validFolder($f): bool {
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
}
private function validFile($f): bool { private function validFile($f): bool {
$f = basename((string)$f); $f = basename((string)$f);
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f); return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
@@ -56,6 +53,24 @@ class MediaController
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden"; return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
} }
private function validFolder($f): bool {
if ($f === 'root') return true;
// Validate per-segment against your REGEX_FOLDER_NAME
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
if (!$parts) return false;
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
}
return true;
}
/** “View” means read OR read_own */
private function canViewFolder(string $folder, string $username): bool {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder)
|| ACL::canReadOwn($username, $perms, $folder);
}
/** POST /api/media/updateProgress.php */ /** POST /api/media/updateProgress.php */
public function updateProgress(): void { public function updateProgress(): void {
$this->jsonStart(); $this->jsonStart();
@@ -67,15 +82,15 @@ class MediaController
$d = $this->readJson(); $d = $this->readJson();
$folder = $this->normalizeFolder($d['folder'] ?? 'root'); $folder = $this->normalizeFolder($d['folder'] ?? 'root');
$file = (string)($d['file'] ?? ''); $file = (string)($d['file'] ?? '');
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0; $seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
$duration = isset($d['duration']) ? floatval($d['duration']) : null; $duration = isset($d['duration']) ? (float)$d['duration'] : null;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null; $completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = isset($d['clear']) ? (bool)$d['clear'] : false; $clear = !empty($d['clear']);
if (!$this->validFolder($folder) || !$this->validFile($file)) { if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return; $this->out(['error'=>'Invalid folder/file'], 400); return;
} }
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; } if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if ($clear) { if ($clear) {
$ok = MediaModel::clearProgress($u, $folder, $file); $ok = MediaModel::clearProgress($u, $folder, $file);
@@ -102,7 +117,7 @@ class MediaController
if (!$this->validFolder($folder) || !$this->validFile($file)) { if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return; $this->out(['error'=>'Invalid folder/file'], 400); return;
} }
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; } if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
$row = MediaModel::getProgress($u, $folder, $file); $row = MediaModel::getProgress($u, $folder, $file);
$this->out(['state'=>$row]); $this->out(['state'=>$row]);
@@ -123,7 +138,12 @@ class MediaController
if (!$this->validFolder($folder)) { if (!$this->validFolder($folder)) {
$this->out(['error'=>'Invalid folder'], 400); return; $this->out(['error'=>'Invalid folder'], 400); return;
} }
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
// Soft-fail for restricted users: avoid noisy console 403s
if (!$this->canViewFolder($folder, $u)) {
$this->out(['map' => []]); // 200 OK, no leakage
return;
}
$map = MediaModel::getFolderMap($u, $folder); $map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]); $this->out(['map'=>$map]);

View File

@@ -17,6 +17,23 @@ private const OO_SUPPORTED_EXTS = [
'pdf' 'pdf'
]; ];
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
private function effectiveFileOriginForDocs(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
// 1) explicit constant
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
}
// 2) admin.json setting
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
// 3) fallback: whatever the public sees (may hairpin, but still works)
return $this->effectivePublicOrigin();
}
// Never editable via OO (well always set edit=false for these) // Never editable via OO (well always set edit=false for these)
private const OO_NEVER_EDIT = ['pdf']; private const OO_NEVER_EDIT = ['pdf'];
@@ -139,13 +156,18 @@ private function ooLog(string $level, string $msg): void
$enabled = $enabled && ($docsOrig !== '') && ($secret !== ''); $enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
$exts = self::OO_SUPPORTED_EXTS; $exts = self::OO_SUPPORTED_EXTS;
// If you want the extras:
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS))); $exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES); echo json_encode([
'enabled' => (bool)$enabled,
'exts' => $exts,
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
], JSON_UNESCAPED_SLASHES);
} }
/** GET /api/onlyoffice/config.php?folder=...&file=... */ /** GET /api/onlyoffice/config.php?folder=...&file=... */
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
public function config(): void public function config(): void
{ {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -156,51 +178,45 @@ private function ooLog(string $level, string $msg): void
$perms = []; $perms = [];
$isAdmin = \ACL::isAdmin($perms); $isAdmin = \ACL::isAdmin($perms);
// Effective toggles
$enabled = $this->effectiveEnabled(); $enabled = $this->effectiveEnabled();
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/'); $docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
$secret = $this->effectiveSecret(); $secret = $this->effectiveSecret();
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; } if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; } if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; } if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; } if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
// Inputs
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root')); $folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
$file = basename((string)($_GET['file'] ?? '')); $file = basename((string)($_GET['file'] ?? ''));
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; } if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
// ACL
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; } if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
$canEdit = \ACL::canEdit($user, $perms, $folder); $canEdit = \ACL::canEdit($user, $perms, $folder);
// Path
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR; $base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/'); $rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file); $abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; } if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; } if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
// Public origin // IMPORTANT: use the internal/fast origin for DocServer fetch + callback
$publicOrigin = $this->effectivePublicOrigin(); $fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
// Signed download
$exp = time() + 10*60; $exp = time() + 10*60;
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES); $data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $data, $secret, true); $sig = hash_hmac('sha256', $data, $secret, true);
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig); $tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok); $fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
// Callback
$cbExp = time() + 10*60; $cbExp = time() + 10*60;
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret); $cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php' $callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
. '?folder=' . rawurlencode($folder) . '?folder=' . rawurlencode($folder)
. '&file=' . rawurlencode($file) . '&file=' . rawurlencode($file)
. '&exp=' . $cbExp . '&exp=' . $cbExp
. '&sig=' . $cbSig; . '&sig=' . $cbSig;
// Doc type & key
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx'); $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell' $docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word'); : (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
@@ -234,7 +250,10 @@ private function ooLog(string $level, string $msg): void
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES)); $p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true)); $s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
$cfgOut['token'] = "$h.$p.$s"; $cfgOut['token'] = "$h.$p.$s";
// expose to client for preconnect/script load
$cfgOut['docs_api_js'] = $docsApiJs; $cfgOut['docs_api_js'] = $docsApiJs;
$cfgOut['documentServerOrigin'] = $docsOrigin;
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES); echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
} }
@@ -374,10 +393,21 @@ private function ooLog(string $level, string $msg): void
if (!$abs || !is_file($abs)) { http_response_code(404); return; } if (!$abs || !is_file($abs)) { http_response_code(404); return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; } if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
// Common headers
$mime = mime_content_type($abs) ?: 'application/octet-stream'; $mime = mime_content_type($abs) ?: 'application/octet-stream';
$len = filesize($abs);
header('Content-Type: '.$mime); header('Content-Type: '.$mime);
header('Content-Length: '.filesize($abs)); header('Content-Length: '.$len);
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"'); header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
header('Accept-Ranges: none'); // OO doesnt require ranges; avoids partial edge-cases
// ---- Key change: for HEAD, do NOT read the file ----
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
// send headers only; no body
return;
}
// GET → stream the file
readfile($abs); readfile($abs);
} }
} }

View File

@@ -0,0 +1,123 @@
<?php
// src/controllers/PortalController.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
final class PortalController
{
/**
* Look up a portal by slug from the Pro bundle.
*
* Returns:
* [
* 'slug' => string,
* 'label' => string,
* 'folder' => string,
* 'clientEmail' => string,
* 'uploadOnly' => bool,
* 'allowDownload' => bool,
* 'expiresAt' => string,
* 'title' => string,
* 'introText' => string,
* 'requireForm' => bool
* ]
*/
public static function getPortalBySlug(string $slug): array
{
$slug = trim($slug);
if ($slug === '') {
throw new InvalidArgumentException('Missing portal slug.');
}
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
throw new RuntimeException('FileRise Pro is not active.');
}
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('Pro bundle directory not configured.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$portals = $store->listPortals();
if (!isset($portals[$slug]) || !is_array($portals[$slug])) {
throw new RuntimeException('Portal not found.');
}
$p = $portals[$slug];
$label = trim((string)($p['label'] ?? $slug));
$folder = trim((string)($p['folder'] ?? ''));
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
$uploadOnly = !empty($p['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $p)
? !empty($p['allowDownload'])
: true;
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
// NEW: optional branding + intake behavior
$title = trim((string)($p['title'] ?? ''));
$introText = trim((string)($p['introText'] ?? ''));
$requireForm = !empty($p['requireForm']);
$brandColor = trim((string)($p['brandColor'] ?? ''));
$footerText = trim((string)($p['footerText'] ?? ''));
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
? $p['formDefaults']
: [];
$formDefaults = [
'name' => trim((string)($fd['name'] ?? '')),
'email' => trim((string)($fd['email'] ?? '')),
'reference' => trim((string)($fd['reference'] ?? '')),
'notes' => trim((string)($fd['notes'] ?? '')),
];
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
? $p['formRequired']
: [];
$formRequired = [
'name' => !empty($fr['name']),
'email' => !empty($fr['email']),
'reference' => !empty($fr['reference']),
'notes' => !empty($fr['notes']),
];
if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.');
}
// Expiry check
if ($expiresAt !== '') {
$ts = strtotime($expiresAt . ' 23:59:59');
if ($ts !== false && $ts < time()) {
throw new RuntimeException('This portal has expired.');
}
}
return [
'slug' => $slug,
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
];
}
}

View File

@@ -5,21 +5,33 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php'; require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController { class UploadController
{
public function handleUpload(): void { public function handleUpload(): void
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// ---- 1) CSRF (header or form field) ---- $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestParams = ($method === 'GET') ? $_GET : $_POST;
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$isResumableTest =
($method === 'GET'
&& isset($requestParams['resumableChunkNumber'])
&& isset($requestParams['resumableIdentifier']));
// ---- 1) CSRF (skip for resumable GET tests Resumable only cares about HTTP status) ----
if (!$isResumableTest) {
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER); $headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = ''; $received = '';
if (!empty($headersArr['x-csrf-token'])) { if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']); $received = trim($headersArr['x-csrf-token']);
} elseif (!empty($_POST['csrf_token'])) { } elseif (!empty($requestParams['csrf_token'])) {
$received = trim($_POST['csrf_token']); $received = trim((string)$requestParams['csrf_token']);
} elseif (!empty($_POST['upload_token'])) { } elseif (!empty($requestParams['upload_token'])) {
// legacy alias // legacy alias
$received = trim($_POST['upload_token']); $received = trim((string)$requestParams['upload_token']);
} }
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
@@ -28,10 +40,11 @@ class UploadController {
http_response_code(200); http_response_code(200);
echo json_encode([ echo json_encode([
'csrf_expired' => true, 'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'] 'csrf_token' => $_SESSION['csrf_token'],
]); ]);
return; return;
} }
}
// ---- 2) Auth + account-level flags ---- // ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) { if (empty($_SESSION['authenticated'])) {
@@ -52,9 +65,9 @@ class UploadController {
} }
// ---- 3) Folder-level WRITE permission (ACL) ---- // ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed. // Prefer the unified param array, fall back to GET only if needed.
$folderParam = isset($_POST['folder']) $folderParam = isset($requestParams['folder'])
? (string)$_POST['folder'] ? (string)$requestParams['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'); : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize // Decode %xx (e.g., "test%20folder") then normalize
@@ -62,44 +75,58 @@ class UploadController {
$targetFolder = ACL::normalizeFolder($folderParam); $targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks // Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) { if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']); echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return; return;
} }
// ---- 4) Delegate to model (force the sanitized folder) ---- // ---- 4) Delegate to model (force the sanitized folder) ----
$_POST['folder'] = $targetFolder; // in case model reads superglobal $requestParams['folder'] = $targetFolder;
$post = $_POST; // Keep legacy behavior for anything still reading $_POST directly
$post['folder'] = $targetFolder; $_POST['folder'] = $targetFolder;
$result = UploadModel::handleUpload($post, $_FILES); $result = UploadModel::handleUpload($requestParams, $_FILES);
// ---- 5) Response (unchanged) ---- // ---- 5) Special handling for Resumable.js GET tests ----
// Resumable only inspects HTTP status:
// 200 => chunk exists (skip)
// 404/other => chunk missing (upload)
if ($isResumableTest && isset($result['status'])) {
if ($result['status'] === 'found') {
http_response_code(200);
} else {
http_response_code(202); // 202 Accepted = chunk not found
}
echo json_encode($result);
return;
}
// ---- 6) Normal response handling ----
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(400); http_response_code(400);
echo json_encode($result); echo json_encode($result);
return; return;
} }
if (isset($result['status'])) { if (isset($result['status'])) {
echo json_encode($result); echo json_encode($result);
return; return;
} }
echo json_encode([ echo json_encode([
'success' => 'File uploaded successfully', 'success' => $result['success'] ?? 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null 'newFilename' => $result['newFilename'] ?? null,
]); ]);
} }
public function removeChunks(): void { public function removeChunks(): void
{
header('Content-Type: application/json'); header('Content-Type: application/json');
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : ''; $receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) { if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']); echo json_encode(['error' => 'Invalid CSRF token']);

View File

@@ -272,6 +272,15 @@ class UserController
echo json_encode(["error" => "No username in session"]); echo json_encode(["error" => "No username in session"]);
exit; exit;
} }
// Block changing the demo account password when in demo mode
if (FR_DEMO_MODE && $username === 'demo') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => 'Password changes are disabled on the public demo.'
]);
exit;
}
$data = self::readJson(); $data = self::readJson();
$oldPassword = trim($data["oldPassword"] ?? ""); $oldPassword = trim($data["oldPassword"] ?? "");
@@ -318,6 +327,14 @@ class UserController
exit; exit;
} }
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
echo json_encode([
'error' => 'TOTP settings are disabled for the demo account.'
]);
exit;
}
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false; $totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
$result = UserModel::updateUserPanel($username, $totp_enabled); $result = UserModel::updateUserPanel($username, $totp_enabled);
echo json_encode($result); echo json_encode($result);
@@ -339,6 +356,14 @@ class UserController
exit; exit;
} }
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
echo json_encode([
'error' => 'TOTP settings are disabled for the demo account.'
]);
exit;
}
$result = UserModel::disableTOTPSecret($username); $result = UserModel::disableTOTPSecret($username);
if ($result) { if ($result) {
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]); echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
@@ -403,6 +428,16 @@ class UserController
} }
$userId = $_SESSION['username']; $userId = $_SESSION['username'];
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $userId === 'demo') {
http_response_code(403);
echo json_encode([
'status' => 'error',
'message' => 'TOTP settings are disabled for the demo account.',
]);
exit;
}
if (!preg_match(REGEX_USER, $userId)) { if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']); echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
@@ -429,6 +464,14 @@ class UserController
exit; exit;
} }
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'TOTP setup is disabled for the demo account.']);
}
self::requireCsrf(); self::requireCsrf();
// Fix: if username not present (pending flow), fall back to pending_login_user // Fix: if username not present (pending flow), fall back to pending_login_user
@@ -608,6 +651,15 @@ class UserController
self::requireAuth(); self::requireAuth();
self::requireCsrf(); self::requireCsrf();
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Profile picture changes are disabled in the demo environment.',
]);
exit;
}
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) { if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']); echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
@@ -649,8 +701,16 @@ class UserController
exit; exit;
} }
// Assuming /uploads maps to UPLOAD_DIR publicly $fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
$url = '/uploads/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
$result = UserModel::setProfilePicture($_SESSION['username'], $url); $result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!($result['success'] ?? false)) { if (!($result['success'] ?? false)) {
@@ -667,6 +727,76 @@ class UserController
exit; exit;
} }
/**
* Upload branding logo (Pro-only; admin, CSRF).
* Reuses the profile_pics directory but does NOT change the user's avatar.
*/
public function uploadBrandLogo()
{
self::jsonHeaders();
// Auth, admin & CSRF
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['brand_logo'];
// Validate MIME & size (same rules as uploadPicture)
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
exit;
}
if ($file['size'] > 2 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'File too large']);
exit;
}
// Destination: reuse profile_pics directory
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
$ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
$filename = 'branding_' . $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
echo json_encode(['success' => true, 'url' => $url]);
exit;
}
public function siteConfig(): void public function siteConfig(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');

View File

@@ -10,23 +10,38 @@ class ACL
private static $path = null; private static $path = null;
private const BUCKETS = [ private const BUCKETS = [
'owners','read','write','share','read_own', 'owners',
'create','upload','edit','rename','copy','move','delete','extract', 'read',
'share_file','share_folder' 'write',
'share',
'read_own',
'create',
'upload',
'edit',
'rename',
'copy',
'move',
'delete',
'extract',
'share_file',
'share_folder'
]; ];
private static function path(): string { private static function path(): string
{
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json'; if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path; return self::$path;
} }
public static function normalizeFolder(string $f): string { public static function normalizeFolder(string $f): string
{
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n"); $f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root'; if ($f === '' || $f === 'root') return 'root';
return $f; return $f;
} }
public static function purgeUser(string $user): bool { public static function purgeUser(string $user): bool
{
$user = (string)$user; $user = (string)$user;
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$changed = false; $changed = false;
@@ -58,6 +73,63 @@ class ACL
return false; 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];
}
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */ /** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void public static function renameTree(string $oldFolder, string $newFolder): void
{ {
@@ -83,7 +155,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
self::save($acl); self::save($acl);
} }
private static function loadFresh(): array { private static function loadFresh(): array
{
$path = self::path(); $path = self::path();
if (!is_file($path)) { if (!is_file($path)) {
@mkdir(dirname($path), 0755, true); @mkdir(dirname($path), 0755, true);
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$healed = false; $healed = false;
foreach ($data['folders'] as $folder => &$rec) { foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; } if (!is_array($rec)) {
$rec = [];
$healed = true;
}
foreach (self::BUCKETS as $k) { foreach (self::BUCKETS as $k) {
$v = $rec[$k] ?? []; $v = $rec[$k] ?? [];
if (!is_array($v)) { $v = []; $healed = true; } if (!is_array($v)) {
$v = [];
$healed = true;
}
$v = array_values(array_unique(array_map('strval', $v))); $v = array_values(array_unique(array_map('strval', $v)));
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; } if (($rec[$k] ?? null) !== $v) {
$rec[$k] = $v;
$healed = true;
}
} }
} }
unset($rec); unset($rec);
@@ -145,19 +227,182 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return $data; return $data;
} }
private static function save(array $acl): bool {
/**
* Load Pro user groups from FR_PRO_BUNDLE_DIR/users/pro/groups.json.
* Returns a map: groupName => ['name','label','members'=>[],'grants'=>[]]
* When Pro is inactive or no file exists, returns an empty array.
*/
private static function loadGroupData(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return [];
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) return [];
static $loaded = false;
static $cache = [];
static $mtime = 0;
$base = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\");
if ($base === '') return [];
$file = $base . DIRECTORY_SEPARATOR . 'groups.json';
$mt = @filemtime($file) ?: 0;
if ($loaded && $mtime === $mt) {
return $cache;
}
$loaded = true;
$mtime = $mt;
if (!$mt || !is_file($file)) {
$cache = [];
return $cache;
}
$raw = @file_get_contents($file);
if ($raw === false || $raw === '') {
$cache = [];
return $cache;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$cache = [];
return $cache;
}
$groups = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : $data;
$norm = [];
foreach ($groups as $key => $g) {
if (!is_array($g)) continue;
$name = isset($g['name']) ? (string)$g['name'] : (string)$key;
$name = trim($name);
if ($name === '') continue;
$g['name'] = $name;
$g['label'] = isset($g['label']) ? (string)$g['label'] : $name;
if (!isset($g['members']) || !is_array($g['members'])) {
$g['members'] = [];
} else {
$g['members'] = array_values(array_unique(array_map('strval', $g['members'])));
}
if (!isset($g['grants']) || !is_array($g['grants'])) {
$g['grants'] = [];
}
$norm[$name] = $g;
}
$cache = $norm;
return $cache;
}
/**
* Map a group grants record for a single folder to a capability bucket.
* Supports both internal bucket keys and the UI-style keys: view, viewOwn,
* manage, shareFile, shareFolder.
*/
private static function groupGrantsCap(array $grants, string $capKey): bool
{
// Direct match (owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder)
if (array_key_exists($capKey, $grants) && $grants[$capKey] === true) {
return true;
}
switch ($capKey) {
case 'read':
return !empty($grants['view']);
case 'read_own':
// Full view always implies own
if (!empty($grants['view'])) return true;
return !empty($grants['viewOwn']);
case 'share_file':
if (!empty($grants['share_file'])) return true;
return !empty($grants['shareFile']);
case 'share_folder':
if (!empty($grants['share_folder'])) return true;
return !empty($grants['shareFolder']);
case 'write':
case 'create':
case 'upload':
case 'edit':
case 'rename':
case 'copy':
case 'move':
case 'delete':
case 'extract':
if (!empty($grants[$capKey])) return true;
// Group "manage" implies all write-ish caps
return !empty($grants['manage']);
case 'share':
if (!empty($grants['share'])) return true;
// Manage can optionally imply share; this keeps UI simple
return !empty($grants['manage']);
}
return false;
}
/**
* Check whether any Pro group the user belongs to grants this cap for folder.
* Groups are additive only; they never remove access.
*/
private static function groupHasGrant(string $user, string $folder, string $capKey): bool
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return false;
$user = (string)$user;
if ($user === '') return false;
$folder = self::normalizeFolder($folder);
if ($folder === '') $folder = 'root';
$groups = self::loadGroupData();
if (!$groups) return false;
foreach ($groups as $g) {
if (!is_array($g)) continue;
$members = $g['members'] ?? [];
$isMember = false;
if (is_array($members)) {
foreach ($members as $m) {
if (strcasecmp((string)$m, $user) === 0) {
$isMember = true;
break;
}
}
}
if (!$isMember) continue;
$folderGrants = $g['grants'][$folder] ?? null;
if (!is_array($folderGrants)) continue;
if (self::groupGrantsCap($folderGrants, $capKey)) {
return true;
}
}
return false;
}
private static function save(array $acl): bool
{
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; $ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl; if ($ok) self::$cache = $acl;
return $ok; return $ok;
} }
private static function listFor(string $folder, string $key): array { private static function listFor(string $folder, string $key): array
{
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null; $f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : []; return is_array($f[$key] ?? null) ? $f[$key] : [];
} }
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void { public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) { if (!isset($acl['folders'][$folder])) {
@@ -182,55 +427,77 @@ public static function renameTree(string $oldFolder, string $newFolder): void
} }
} }
public static function isAdmin(array $perms = []): bool { public static function isAdmin(array $perms = []): bool
{
if (!empty($_SESSION['isAdmin'])) return true; if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true; if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true; if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username']) if (
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) { defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
) {
return true; return true;
} }
return false; return false;
} }
public static function hasGrant(string $user, string $folder, string $cap): bool { public static function hasGrant(string $user, string $folder, string $cap): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap; $capKey = ($cap === 'owner') ? 'owners' : $cap;
// 1) Core per-folder ACL buckets (folder_acl.json)
$arr = self::listFor($folder, $capKey); $arr = self::listFor($folder, $capKey);
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true; foreach ($arr as $u) {
if (strcasecmp((string)$u, $user) === 0) {
return true;
}
}
// 2) Pro user groups (if enabled) additive only
if (self::groupHasGrant($user, $folder, $capKey)) {
return true;
}
return false; return false;
} }
public static function isOwner(string $user, array $perms, string $folder): bool { public static function isOwner(string $user, array $perms, string $folder): bool
{
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners'); return self::hasGrant($user, $folder, 'owners');
} }
public static function canManage(string $user, array $perms, string $folder): bool { public static function canManage(string $user, array $perms, string $folder): bool
{
return self::isOwner($user, $perms, $folder); return self::isOwner($user, $perms, $folder);
} }
public static function canRead(string $user, array $perms, string $folder): bool { public static function canRead(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read'); || self::hasGrant($user, $folder, 'read');
} }
public static function canReadOwn(string $user, array $perms, string $folder): bool { public static function canReadOwn(string $user, array $perms, string $folder): bool
{
if (self::canRead($user, $perms, $folder)) return true; if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own'); return self::hasGrant($user, $folder, 'read_own');
} }
public static function canWrite(string $user, array $perms, string $folder): bool { public static function canWrite(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canShare(string $user, array $perms, string $folder): bool { public static function canShare(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -238,7 +505,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
} }
// Legacy-only explicit (to avoid breaking existing callers) // Legacy-only explicit (to avoid breaking existing callers)
public static function explicit(string $folder): array { public static function explicit(string $folder): array
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? []; $rec = $acl['folders'][$folder] ?? [];
@@ -257,7 +525,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
} }
// New: full explicit including granular // New: full explicit including granular
public static function explicitAll(string $folder): array { public static function explicitAll(string $folder): array
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? []; $rec = $acl['folders'][$folder] ?? [];
@@ -285,7 +554,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
]; ];
} }
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool { public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$existing = $acl['folders'][$folder] ?? ['read_own' => []]; $existing = $acl['folders'][$folder] ?? ['read_own' => []];
@@ -314,13 +584,17 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return self::save($acl); return self::save($acl);
} }
public static function applyUserGrantsAtomic(string $user, array $grants): array { public static function applyUserGrantsAtomic(string $user, array $grants): array
{
$user = (string)$user; $user = (string)$user;
$path = self::path(); $path = self::path();
$fh = @fopen($path, 'c+'); $fh = @fopen($path, 'c+');
if (!$fh) throw new RuntimeException('Cannot open ACL storage'); if (!$fh) throw new RuntimeException('Cannot open ACL storage');
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); } if (!flock($fh, LOCK_EX)) {
fclose($fh);
throw new RuntimeException('Cannot lock ACL storage');
}
try { try {
$raw = stream_get_contents($fh); $raw = stream_get_contents($fh);
@@ -365,10 +639,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']); $sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']); $sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; } if ($m) {
$v = true;
$w = true;
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
}
if ($u && !$v && !$vo) $vo = true; if ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = true; //if ($s && !$v) $v = true;
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; } if ($w) {
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
}
if ($m) $rec['owners'][] = $user; if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user; if ($v) $rec['read'][] = $user;
@@ -411,7 +691,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
// --- Granular write family ----------------------------------------------- // --- Granular write family -----------------------------------------------
public static function canCreate(string $user, array $perms, string $folder): bool { public static function canCreate(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -419,14 +700,16 @@ public static function canCreate(string $user, array $perms, string $folder): bo
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canCreateFolder(string $user, array $perms, string $folder): bool { public static function canCreateFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder // Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners'); return self::hasGrant($user, $folder, 'owners');
} }
public static function canUpload(string $user, array $perms, string $folder): bool { public static function canUpload(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -434,7 +717,8 @@ public static function canUpload(string $user, array $perms, string $folder): bo
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canEdit(string $user, array $perms, string $folder): bool { public static function canEdit(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -442,7 +726,8 @@ public static function canEdit(string $user, array $perms, string $folder): bool
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canRename(string $user, array $perms, string $folder): bool { public static function canRename(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -450,7 +735,8 @@ public static function canRename(string $user, array $perms, string $folder): bo
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canCopy(string $user, array $perms, string $folder): bool { public static function canCopy(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -458,19 +744,22 @@ public static function canCopy(string $user, array $perms, string $folder): bool
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canMove(string $user, array $perms, string $folder): bool { public static function canMove(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder); return self::ownsFolderOrAncestor($user, $perms, $folder);
} }
public static function canMoveFolder(string $user, array $perms, string $folder): bool { public static function canMoveFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder); return self::ownsFolderOrAncestor($user, $perms, $folder);
} }
public static function canDelete(string $user, array $perms, string $folder): bool { public static function canDelete(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -478,7 +767,8 @@ public static function canDelete(string $user, array $perms, string $folder): bo
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'write');
} }
public static function canExtract(string $user, array $perms, string $folder): bool { public static function canExtract(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
@@ -487,12 +777,14 @@ public static function canExtract(string $user, array $perms, string $folder): b
} }
/** Sharing: files use share, folders require share + full-view. */ /** Sharing: files use share, folders require share + full-view. */
public static function canShareFile(string $user, array $perms, string $folder): bool { public static function canShareFile(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
} }
public static function canShareFolder(string $user, array $perms, string $folder): bool { public static function canShareFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); $can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');

87
src/lib/FS.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
// src/lib/FS.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
final class FS
{
/** Hidden/system names to ignore entirely */
public static function IGNORE(): array {
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
}
/** App-specific names to skip from UI */
public static function SKIP(): array {
return ['trash','profile_pics'];
}
public static function isSafeSegment(string $name): bool {
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
public static function safeReal(string $baseReal, string $p): ?string {
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
/**
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
* $maxDepth intentionally small to avoid expensive scans.
*/
public static function hasReadableDescendant(
string $baseReal,
string $absPath,
string $relPath,
string $user,
array $perms,
int $maxDepth = 2
): bool {
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
$IGNORE = self::IGNORE();
$SKIP = self::SKIP();
$items = @scandir($absPath) ?: [];
foreach ($items as $child) {
if ($child === '.' || $child === '..') continue;
if ($child[0] === '.') continue;
if (in_array($child, $IGNORE, true)) continue;
if (!self::isSafeSegment($child)) continue;
$lower = strtolower($child);
if (in_array($lower, $SKIP, true)) continue;
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
if (!@is_dir($abs)) continue;
// Resolve symlink safely
if (@is_link($abs)) {
$safe = self::safeReal($baseReal, $abs);
if ($safe === null || !is_dir($safe)) continue;
$abs = $safe;
}
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
return true;
}
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
return true;
}
}
return false;
}
}

View File

@@ -73,6 +73,27 @@ class AdminModel
return ($scheme === 'http' || $scheme === 'https') ? $url : ''; return ($scheme === 'http' || $scheme === 'https') ? $url : '';
} }
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
}
return $url;
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
public static function buildPublicSubset(array $config): array public static function buildPublicSubset(array $config): array
{ {
$public = [ $public = [
@@ -89,6 +110,18 @@ class AdminModel
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''), 'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
], ],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
]; ];
// NEW: include ONLYOFFICE minimal public flag // NEW: include ONLYOFFICE minimal public flag
@@ -111,6 +144,7 @@ if ($locked) {
} }
$public['onlyoffice'] = ['enabled' => $ooEnabled]; $public['onlyoffice'] = ['enabled' => $ooEnabled];
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
return $public; return $public;
} }
@@ -227,6 +261,30 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
$configUpdate['onlyoffice'] = $norm; $configUpdate['onlyoffice'] = $norm;
} }
// Branding (Pro-only). Normalize and only persist when Pro is active.
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
} else {
// Free mode: always clear branding customizations
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
}
}
// Convert configuration to JSON. // Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) { if ($plainTextConfig === false) {
@@ -267,6 +325,18 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
return ["success" => "Configuration updated successfully."]; return ["success" => "Configuration updated successfully."];
} }
private static function sanitizeColorHex($value): string
{
$value = trim((string)$value);
if ($value === '') return '';
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
}
return '';
}
/** /**
* Retrieves the current configuration. * Retrieves the current configuration.
* *
@@ -368,6 +438,25 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? ''); $config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
} }
// Branding
if (!isset($config['branding']) || !is_array($config['branding'])) {
$config['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
);
$config['branding']['headerBgLight'] = self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
);
$config['branding']['headerBgDark'] = self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
);
}
return $config; return $config;
} }
@@ -393,6 +482,11 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
'docsOrigin' => '', 'docsOrigin' => '',
'publicOrigin' => '', 'publicOrigin' => '',
], ],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
]; ];
} }
} }

View File

@@ -557,36 +557,52 @@ class FileModel {
* @return array An associative array with either an "error" key or a "zipPath" key. * @return array An associative array with either an "error" key or a "zipPath" key.
*/ */
public static function createZipArchive($folder, $files) { public static function createZipArchive($folder, $files) {
// Validate and build folder path. // Purge old temp zips > 6h (best-effort)
$folder = trim($folder) ?: 'root'; $zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
$now = time();
foreach ((glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) {
if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) { @unlink($zp); }
}
// Normalize and validate target folder
$folder = trim((string)$folder) ?: 'root';
$baseDir = realpath(UPLOAD_DIR); $baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) { if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."]; return ["error" => "Uploads directory not configured correctly."];
} }
if (strtolower($folder) === 'root' || $folder === "") { if (strtolower($folder) === 'root' || $folder === "") {
$folderPathReal = $baseDir; $folderPathReal = $baseDir;
} else { } else {
// Prevent path traversal.
if (strpos($folder, '..') !== false) { if (strpos($folder, '..') !== false) {
return ["error" => "Invalid folder name."]; return ["error" => "Invalid folder name."];
} }
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ "); $parts = explode('/', trim($folder, "/\\ "));
foreach ($parts as $part) {
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
return ["error" => "Invalid folder name."];
}
}
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$folderPathReal = realpath($folderPath); $folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) { if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
return ["error" => "Folder not found."]; return ["error" => "Folder not found."];
} }
} }
// Validate each file and build an array of files to zip. // Collect files to zip (only regular files in the chosen folder)
$filesToZip = []; $filesToZip = [];
foreach ($files as $fileName) { foreach ($files as $fileName) {
// Validate file name using REGEX_FILE_NAME. $fileName = basename(trim((string)$fileName));
$fileName = basename(trim($fileName));
if (!preg_match(REGEX_FILE_NAME, $fileName)) { if (!preg_match(REGEX_FILE_NAME, $fileName)) {
continue; continue;
} }
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName; $fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
if (file_exists($fullPath)) { // Skip symlinks (avoid archiving outside targets via links)
if (is_link($fullPath)) {
continue;
}
if (is_file($fullPath)) {
$filesToZip[] = $fullPath; $filesToZip[] = $fullPath;
} }
} }
@@ -594,22 +610,51 @@ class FileModel {
return ["error" => "No valid files found to zip."]; return ["error" => "No valid files found to zip."];
} }
// Create a temporary ZIP file. // Workspace on the big disk: META_DIR/ziptmp
$tempZip = tempnam(sys_get_temp_dir(), 'zip'); $work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
unlink($tempZip); // Remove the temp file so that ZipArchive can create a new file. if (!is_dir($work)) { @mkdir($work, 0775, true); }
$tempZip .= '.zip'; if (!is_dir($work) || !is_writable($work)) {
return ["error" => "ZIP temp dir not writable: " . $work];
}
$zip = new ZipArchive(); // Optional sanity: ensure there is roughly enough free space
if ($zip->open($tempZip, ZipArchive::CREATE) !== TRUE) { $totalSize = 0;
foreach ($filesToZip as $fp) {
$sz = @filesize($fp);
if ($sz !== false) $totalSize += (int)$sz;
}
$free = @disk_free_space($work);
// Add ~20MB overhead and a 5% cushion
if ($free !== false && $totalSize > 0) {
$needed = (int)ceil($totalSize * 1.05) + (20 * 1024 * 1024);
if ($free < $needed) {
return ["error" => "Insufficient free space in ZIP workspace."];
}
}
@set_time_limit(0);
// Create the ZIP path inside META_DIR/ziptmp (libzip temp stays on same FS)
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $work . DIRECTORY_SEPARATOR . $zipName;
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
return ["error" => "Could not create zip archive."]; return ["error" => "Could not create zip archive."];
} }
// Add each file using its base name.
foreach ($filesToZip as $filePath) { foreach ($filesToZip as $filePath) {
// Add using basename at the root of the zip (matches current behavior)
$zip->addFile($filePath, basename($filePath)); $zip->addFile($filePath, basename($filePath));
} }
$zip->close();
return ["zipPath" => $tempZip]; if (!$zip->close()) {
// Commonly indicates disk full at finalize
return ["error" => "Failed to finalize ZIP (disk full?)."];
}
// Success: controller will readfile() and unlink()
return ["zipPath" => $zipPath];
} }
/** /**
@@ -624,6 +669,13 @@ class FileModel {
$allSuccess = true; $allSuccess = true;
$extractedFiles = []; $extractedFiles = [];
// Config toggles
$SKIP_DOTFILES = defined('SKIP_DOTFILES_ON_EXTRACT') ? (bool)SKIP_DOTFILES_ON_EXTRACT : true;
// Hard limits to mitigate zip-bombs (tweak via defines if you like)
$MAX_UNZIP_BYTES = defined('MAX_UNZIP_BYTES') ? (int)MAX_UNZIP_BYTES : (200 * 1024 * 1024 * 1024); // 200 GiB
$MAX_UNZIP_FILES = defined('MAX_UNZIP_FILES') ? (int)MAX_UNZIP_FILES : 20000;
$baseDir = realpath(UPLOAD_DIR); $baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) { if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."]; return ["error" => "Uploads directory not configured correctly."];
@@ -632,6 +684,7 @@ class FileModel {
// Build target dir // Build target dir
if (strtolower(trim($folder) ?: '') === "root") { if (strtolower(trim($folder) ?: '') === "root") {
$relativePath = ""; $relativePath = "";
$folderNorm = "root";
} else { } else {
$parts = explode('/', trim($folder, "/\\")); $parts = explode('/', trim($folder, "/\\"));
foreach ($parts as $part) { foreach ($parts as $part) {
@@ -640,6 +693,7 @@ class FileModel {
} }
} }
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; $relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
$folderNorm = implode('/', $parts); // normalized with forward slashes for metadata helpers
} }
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath; $folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
@@ -651,16 +705,73 @@ class FileModel {
return ["error" => "Folder not found."]; return ["error" => "Folder not found."];
} }
// Prepare metadata container // Metadata cache per folder to avoid many reads/writes
$metadataFile = self::getMetadataFilePath($folder); $metaCache = [];
$destMetadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : []; $getMeta = function(string $folderStr) use (&$metaCache) {
if (!isset($metaCache[$folderStr])) {
$mf = self::getMetadataFilePath($folderStr);
$metaCache[$folderStr] = file_exists($mf) ? (json_decode(file_get_contents($mf), true) ?: []) : [];
}
return $metaCache[$folderStr];
};
$putMeta = function(string $folderStr, array $meta) use (&$metaCache) {
$metaCache[$folderStr] = $meta;
};
$safeFileNamePattern = REGEX_FILE_NAME; $safeFileNamePattern = REGEX_FILE_NAME;
$actor = $_SESSION['username'] ?? 'Unknown'; $actor = $_SESSION['username'] ?? 'Unknown';
$now = date(DATE_TIME_FORMAT); $now = date(DATE_TIME_FORMAT);
// --- Helpers ---
// Reject absolute paths, traversal, drive letters
$isUnsafeEntryPath = function(string $entry) : bool {
$e = str_replace('\\', '/', $entry);
if ($e === '' || str_contains($e, "\0")) return true;
if (str_starts_with($e, '/')) return true; // absolute nix path
if (preg_match('/^[A-Za-z]:[\\/]/', $e)) return true; // Windows drive
if (str_contains($e, '../') || str_contains($e, '..\\')) return true;
return false;
};
// Validate each subfolder name in the path using REGEX_FOLDER_NAME
$validEntrySubdirs = function(string $entry) : bool {
$e = trim(str_replace('\\', '/', $entry), '/');
if ($e === '') return true;
$dirs = explode('/', $e);
array_pop($dirs); // remove basename; we only validate directories here
foreach ($dirs as $d) {
if ($d === '' || !preg_match(REGEX_FOLDER_NAME, $d)) return false;
}
return true;
};
// NEW: hidden path detector — true if ANY segment starts with '.'
$isHiddenDotPath = function(string $entry) : bool {
$e = trim(str_replace('\\', '/', $entry), '/');
if ($e === '') return false;
foreach (explode('/', $e) as $seg) {
if ($seg !== '' && $seg[0] === '.') return true;
}
return false;
};
// Generalized metadata stamper: writes to the specified folder's metadata.json
$stampMeta = function(string $folderStr, string $basename) use (&$getMeta, &$putMeta, $actor, $now) {
$meta = $getMeta($folderStr);
$meta[$basename] = [
'uploaded' => $now,
'modified' => $now,
'uploader' => $actor,
];
$putMeta($folderStr, $meta);
};
// No PHP execution time limit during heavy work
@set_time_limit(0);
foreach ($files as $zipFileName) { foreach ($files as $zipFileName) {
$zipBase = basename(trim($zipFileName)); $zipBase = basename(trim((string)$zipFileName));
if (strtolower(substr($zipBase, -4)) !== '.zip') { if (strtolower(substr($zipBase, -4)) !== '.zip') {
continue; continue;
} }
@@ -677,67 +788,126 @@ class FileModel {
continue; continue;
} }
$zip = new ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($zipFilePath) !== TRUE) { if ($zip->open($zipFilePath) !== true) {
$errors[] = "Could not open $zipBase as a zip file."; $errors[] = "Could not open $zipBase as a zip file.";
$allSuccess = false; $allSuccess = false;
continue; continue;
} }
// Minimal Zip Slip guard: fail if any entry looks unsafe // ---- Pre-scan: safety and size limits + build allow-list (skip dotfiles) ----
$unsafe = false; $unsafe = false;
$totalUncompressed = 0;
$fileCount = 0;
$allowedEntries = []; // names to extract (files and/or directories)
$allowedFiles = []; // only files (for metadata stamping)
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i); $stat = $zip->statIndex($i);
if ($entryName === false) { $unsafe = true; break; } $name = $zip->getNameIndex($i);
// Absolute paths, parent traversal, or Windows drive paths if ($name === false || !$stat) { $unsafe = true; break; }
if (strpos($entryName, '../') !== false || strpos($entryName, '..\\') !== false ||
str_starts_with($entryName, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $entryName)) { $isDir = str_ends_with($name, '/');
// Basic path checks
if ($isUnsafeEntryPath($name) || !$validEntrySubdirs($name)) { $unsafe = true; break; }
// Skip hidden entries (any segment starts with '.')
if ($SKIP_DOTFILES && $isHiddenDotPath($name)) {
continue; // just ignore; do not treat as unsafe
}
// Detect symlinks via external attributes (best-effort)
$mode = (isset($stat['external_attributes']) ? (($stat['external_attributes'] >> 16) & 0xF000) : 0);
if ($mode === 0120000) { // S_IFLNK
$unsafe = true; break; $unsafe = true; break;
} }
// Track limits only for files we're going to extract
if (!$isDir) {
$fileCount++;
$sz = isset($stat['size']) ? (int)$stat['size'] : 0;
$totalUncompressed += $sz;
if ($fileCount > $MAX_UNZIP_FILES || $totalUncompressed > $MAX_UNZIP_BYTES) {
$unsafe = true; break;
} }
$allowedFiles[] = $name;
}
$allowedEntries[] = $name;
}
if ($unsafe) { if ($unsafe) {
$zip->close(); $zip->close();
$errors[] = "$zipBase contains unsafe paths; extraction aborted."; $errors[] = "$zipBase contains unsafe or oversized contents; extraction aborted.";
$allSuccess = false; $allSuccess = false;
continue; continue;
} }
// Extract safely (whole archive) after precheck // Nothing to extract after filtering?
if (!$zip->extractTo($folderPathReal)) { if (empty($allowedEntries)) {
$zip->close();
// Treat as success (nothing visible to extract), but informatively note it
$errors[] = "$zipBase contained only hidden or unsupported entries.";
$allSuccess = false; // or keep true if you'd rather not mark as failure
continue;
}
// ---- Extract ONLY the allowed entries ----
if (!$zip->extractTo($folderPathReal, $allowedEntries)) {
$errors[] = "Failed to extract $zipBase."; $errors[] = "Failed to extract $zipBase.";
$allSuccess = false; $allSuccess = false;
$zip->close(); $zip->close();
continue; continue;
} }
// Stamp metadata for extracted regular files // ---- Stamp metadata for files in the target folder AND nested subfolders (allowed files only) ----
for ($i = 0; $i < $zip->numFiles; $i++) { foreach ($allowedFiles as $entryName) {
$entryName = $zip->getNameIndex($i); // Normalize entry path for filesystem checks
if ($entryName === false) continue; $entryFsRel = str_replace(['\\'], '/', $entryName);
$entryFsRel = ltrim($entryFsRel, '/'); // ensure relative
$basename = basename($entryName); // Skip any directories (shouldn't be listed here, but defend anyway)
if ($entryFsRel === '' || str_ends_with($entryFsRel, '/')) continue;
$basename = basename($entryFsRel);
if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue; if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue;
// Only stamp files that actually exist after extraction // Decide which folder's metadata to update:
$target = $folderPathReal . DIRECTORY_SEPARATOR . $entryName; // - top-level files -> $folderNorm
$isDir = str_ends_with($entryName, '/') || is_dir($target); // - nested files -> corresponding "<folderNorm>/<sub/dir>" (or "sub/dir" if folderNorm is 'root')
if ($isDir) continue; $relDir = str_replace('\\', '/', trim(dirname($entryFsRel), '.'));
$relDir = ($relDir === '.' ? '' : trim($relDir, '/'));
$targetFolderNorm = ($relDir === '' || $relDir === '.')
? $folderNorm
: (($folderNorm === 'root') ? $relDir : ($folderNorm . '/' . $relDir));
// Only stamp if the file actually exists on disk after extraction
$targetAbs = $folderPathReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entryFsRel);
if (is_file($targetAbs)) {
// Preserve list behavior: only include top-level extracted names
if ($relDir === '' || $relDir === '.') {
$extractedFiles[] = $basename; $extractedFiles[] = $basename;
$destMetadata[$basename] = [
'uploaded' => $now,
'modified' => $now,
'uploader' => $actor,
// no tags by default
];
} }
$stampMeta($targetFolderNorm, $basename);
}
}
$zip->close(); $zip->close();
} }
if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { // Persist metadata for any touched folder(s)
$errors[] = "Failed to update metadata."; foreach ($metaCache as $folderStr => $meta) {
$metadataFile = self::getMetadataFilePath($folderStr);
if (!is_dir(dirname($metadataFile))) {
@mkdir(dirname($metadataFile), 0775, true);
}
if (file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX) === false) {
$errors[] = "Failed to update metadata for {$folderStr}.";
$allSuccess = false; $allSuccess = false;
} }
}
return $allSuccess return $allSuccess
? ["success" => true, "extractedFiles" => $extractedFiles] ? ["success" => true, "extractedFiles" => $extractedFiles]

Some files were not shown because too many files have changed in this diff Show More