Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 | ||
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 | ||
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d | ||
|
|
5613710411 | ||
|
|
08f7ffccbc | ||
|
|
ad1d41fad8 | ||
|
|
99662cd2f2 | ||
|
|
060a548af4 | ||
|
|
9880adb417 | ||
|
|
a56641e81c | ||
|
|
3b636f69d8 | ||
|
|
930ed954ec | ||
|
|
402f590163 | ||
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 |
217
.github/workflows/release-on-version.yml
vendored
@@ -2,164 +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:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
description: "Ref (branch/sha) to build from (default: master)"
|
||||||
required: false
|
required: false
|
||||||
version:
|
version:
|
||||||
description: "Explicit version tag to release (e.g., v1.8.6). If empty, auto-detect."
|
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
delay:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Delay 2 minutes
|
|
||||||
run: sleep 120
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: delay
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
|
||||||
if: >
|
|
||||||
github.event_name == 'push' ||
|
github.event_name == 'push' ||
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch'
|
||||||
(github.event_name == 'workflow_run' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
|
||||||
|
|
||||||
# Use run_id for a stable, unique key
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.run_id }}
|
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (fetch all)
|
- name: Resolve source ref
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Ensure tags + master available
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
git fetch --tags --force --prune --quiet
|
|
||||||
git fetch origin master --quiet
|
|
||||||
|
|
||||||
- name: Resolve source ref + (maybe) version
|
|
||||||
id: pickref
|
id: pickref
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Defaults
|
|
||||||
REF=""
|
|
||||||
VER=""
|
|
||||||
SRC=""
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
# manual run
|
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||||
REF_IN="${{ github.event.inputs.ref }}"
|
REF_IN="${{ github.event.inputs.ref }}"
|
||||||
VER_IN="${{ github.event.inputs.version }}"
|
|
||||||
if [[ -n "$REF_IN" ]]; then
|
|
||||||
# Try branch/sha; fetch branch if needed
|
|
||||||
git fetch origin "$REF_IN" --quiet || true
|
|
||||||
if REF_SHA="$(git rev-parse --verify --quiet "$REF_IN")"; then
|
|
||||||
REF="$REF_SHA"
|
|
||||||
else
|
|
||||||
echo "Provided ref '$REF_IN' not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
REF="$(git rev-parse origin/master)"
|
REF_IN="master"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$VER_IN" ]]; then
|
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||||
VER="$VER_IN"
|
REF="$REF_IN"
|
||||||
SRC="manual-version"
|
else
|
||||||
|
REF="$REF_IN"
|
||||||
fi
|
fi
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
||||||
REF="${{ github.event.workflow_run.head_sha }}"
|
|
||||||
else
|
else
|
||||||
REF="${{ github.sha }}"
|
REF="${{ github.sha }}"
|
||||||
fi
|
fi
|
||||||
|
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Using ref=$REF"
|
||||||
|
|
||||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||||
if [[ -z "$VER" ]]; then
|
|
||||||
# Search recent history reachable from REF
|
|
||||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
|
||||||
if [[ -n "$BOT_SHA" ]]; then
|
|
||||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
|
||||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
|
||||||
if [[ -n "$BOT_VER" ]]; then
|
|
||||||
VER="$BOT_VER"
|
|
||||||
REF="$BOT_SHA" # build/tag from the bump commit
|
|
||||||
SRC="bot-commit"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Output
|
|
||||||
REF_SHA="$(git rev-parse "$REF")"
|
|
||||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
|
||||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
|
||||||
|
|
||||||
- name: Checkout chosen ref
|
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ steps.pickref.outputs.ref }}
|
ref: ${{ steps.pickref.outputs.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
- name: Assert ref is on master
|
persist-credentials: false
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
REF="${{ steps.pickref.outputs.ref }}"
|
|
||||||
git fetch origin master --quiet
|
|
||||||
if ! git merge-base --is-ancestor "$REF" origin/master; then
|
|
||||||
echo "Ref $REF is not on master; refusing to release."
|
|
||||||
exit 78
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Debug version.js provenance
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "version.js last-change commit: $(git log -n1 --pretty='%h %s' -- public/js/version.js || echo 'none')"
|
|
||||||
sed -n '1,20p' public/js/version.js || true
|
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: ver
|
id: ver
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Prefer pre-resolved version (manual input or bot commit)
|
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
VER="${{ github.event.inputs.version }}"
|
||||||
VER="${{ steps.pickref.outputs.preversion }}"
|
else
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
if [[ ! -f public/js/version.js ]]; then
|
||||||
echo "Parsed version (pre-resolved): $VER"
|
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Fallback to version.js
|
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
if [[ -z "$VER" ]]; then
|
||||||
if [[ -z "$VER" ]]; then
|
echo "Could not parse APP_VERSION from public/js/version.js" >&2
|
||||||
echo "Could not parse APP_VERSION from version.js" >&2
|
exit 1
|
||||||
exit 1
|
fi
|
||||||
fi
|
fi
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||||
echo "Parsed version (file): $VER"
|
echo "Detected version: $VER"
|
||||||
|
|
||||||
- name: Skip if tag already exists
|
- name: Skip if tag already exists
|
||||||
id: tagcheck
|
id: tagcheck
|
||||||
@@ -173,7 +92,7 @@ jobs:
|
|||||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Prep stamper script
|
- name: Prepare stamp script
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -181,7 +100,7 @@ jobs:
|
|||||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||||
chmod +x scripts/stamp-assets.sh
|
chmod +x scripts/stamp-assets.sh
|
||||||
|
|
||||||
- name: Build zip artifact (stamped)
|
- name: Build stamped staging tree
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -195,27 +114,67 @@ jobs:
|
|||||||
./ staging/
|
./ staging/
|
||||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
||||||
|
|
||||||
- name: Verify placeholders are gone (staging)
|
# --- PHP + Composer for vendor/ (production) ---
|
||||||
|
- name: Setup PHP
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
id: php
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
tools: composer:v2
|
||||||
|
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
|
||||||
|
coverage: none
|
||||||
|
ini-values: memory_limit=-1
|
||||||
|
|
||||||
|
- name: Cache Composer downloads
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.composer/cache
|
||||||
|
~/.cache/composer
|
||||||
|
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
|
||||||
|
|
||||||
|
- name: Install PHP dependencies into staging
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
env:
|
||||||
|
COMPOSER_MEMORY_LIMIT: -1
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pushd staging >/dev/null
|
||||||
|
if [[ -f composer.json ]]; then
|
||||||
|
composer install \
|
||||||
|
--no-dev \
|
||||||
|
--prefer-dist \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--optimize-autoloader \
|
||||||
|
--classmap-authoritative
|
||||||
|
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
|
||||||
|
else
|
||||||
|
echo "No composer.json in staging; skipping vendor install."
|
||||||
|
fi
|
||||||
|
popd >/dev/null
|
||||||
|
# --- end Composer ---
|
||||||
|
|
||||||
|
- name: Verify placeholders removed (skip vendor/)
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(pwd)/staging"
|
ROOT="$(pwd)/staging"
|
||||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||||
|
--exclude-dir=vendor --exclude-dir=vendor-bin \
|
||||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||||
echo "---- DEBUG (show 10 hits with context) ----"
|
echo "Unreplaced placeholders found in staging." >&2
|
||||||
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
|
||||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
|
|
||||||
| head -n 10 | while IFS=: read -r file line _; do
|
|
||||||
echo ">>> $file:$line"
|
|
||||||
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
|
|
||||||
echo "----------------------------------------"
|
|
||||||
done
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: No unreplaced placeholders in staging."
|
echo "OK: No unreplaced placeholders."
|
||||||
|
|
||||||
- name: Zip stamped staging
|
- name: Zip artifact (includes vendor/)
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -223,7 +182,7 @@ jobs:
|
|||||||
VER="${{ steps.ver.outputs.version }}"
|
VER="${{ steps.ver.outputs.version }}"
|
||||||
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||||
|
|
||||||
- name: Compute SHA-256 checksum
|
- name: Compute SHA-256
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
id: sum
|
id: sum
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -268,9 +227,9 @@ jobs:
|
|||||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||||
fi
|
fi
|
||||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||||
echo "Previous tag or baseline: $PREV"
|
echo "Previous tag/baseline: $PREV"
|
||||||
|
|
||||||
- name: Build release body (snippet + full changelog + checksum)
|
- name: Build release body
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
768
CHANGELOG.md
@@ -1,5 +1,773 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/27/2025 (v2.1.0)
|
||||||
|
|
||||||
|
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
|
||||||
|
|
||||||
|
release(v2.1.0): add header zoom controls, preview tags & modal/dock polish
|
||||||
|
|
||||||
|
- **feat(ux): header zoom controls with persisted app zoom**
|
||||||
|
- Add `zoom.js` with percent-based zoom API (`window.fileriseZoom`) and `--app-zoom` CSS variable.
|
||||||
|
- Wrap the main app in `#appZoomShell` and scale via `transform: scale(var(--app-zoom))` so the whole UI zooms uniformly.
|
||||||
|
- Add header zoom UI (+ / − / 100% reset) and wire it via `data-zoom` buttons.
|
||||||
|
- Persist zoom level in `localStorage` and restore on load.
|
||||||
|
|
||||||
|
- **feat(prefs): user toggle to hide header zoom controls**
|
||||||
|
- Add `hide_header_zoom_controls` i18n key.
|
||||||
|
- Extend the Settings → Display fieldset with “Hide header zoom controls”.
|
||||||
|
- Store preference in `localStorage('hideZoomControls')` and respect it from `appCore.js` when initializing header zoom UI.
|
||||||
|
|
||||||
|
- **feat(preview): show file tags next to preview title**
|
||||||
|
- Add `.title-tags` container in the media viewer header.
|
||||||
|
- When opening a file, look up its `tags` from `fileData` and render them as pill badges beside the filename in the modal top bar.
|
||||||
|
|
||||||
|
- **fix(modals): folder modals always centered above header cards**
|
||||||
|
- Introduce `detachFolderModalsToBody()` in `folderManager.js` and call it on init + before opening create/rename/move/delete modals.
|
||||||
|
- Move those modals under `document.body` with a stable high `z-index`, so they’re not clipped/hidden when the cards live in the header dock.
|
||||||
|
|
||||||
|
- **fix(dnd): header dock & hidden cards container**
|
||||||
|
- Change `#hiddenCardsContainer` from `display:none` to an off-screen absolutely positioned container so card internals (modals/layout) still work while represented as header icons.
|
||||||
|
- Ensure sidebar is always visible as a drop target while dragging (even when panels are collapsed), plus improved highlight & placeholder behavior.
|
||||||
|
|
||||||
|
- **feat(ux): header dock hover/lock polish**
|
||||||
|
- Make header icon buttons share the same hover style as other header buttons.
|
||||||
|
- Add `.is-locked` state so a pinned header icon stays visually “pressed” while its card modal is locked open.
|
||||||
|
|
||||||
|
- **feat(ux): header drop zone and zoom bar layout**
|
||||||
|
- Rework `.header-right` to neatly align zoom controls, header dock, and user buttons.
|
||||||
|
- Add a more flexible `.header-drop-zone` with smooth width/padding transitions and a centered `"Drop Zone"` label when active and empty.
|
||||||
|
- Adjust responsive spacing around zoom controls on smaller screens.
|
||||||
|
|
||||||
|
- **tweak(prefs-modal): improve settings modal sizing**
|
||||||
|
- Increase auth/settings modal `max-height` from 500px to 600px to fit the extra display options without excessive scrolling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 you’re 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 portal’s 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 user’s 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 Resumable’s 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 can’t view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
|
||||||
|
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
|
||||||
|
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
|
||||||
|
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
|
||||||
|
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
|
||||||
|
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
|
||||||
|
- Root DnD honored only when viewable; structural locks disable dragging.
|
||||||
|
|
||||||
|
perf(core):
|
||||||
|
|
||||||
|
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
|
||||||
|
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
|
||||||
|
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
|
||||||
|
|
||||||
|
api/model:
|
||||||
|
|
||||||
|
- `FolderModel::listChildren(...)` now returns items shaped like:
|
||||||
|
`{ name, locked, hasSubfolders, nonEmpty? }`
|
||||||
|
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
|
||||||
|
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
|
||||||
|
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
|
||||||
|
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
|
||||||
|
|
||||||
|
ui/ux:
|
||||||
|
|
||||||
|
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
|
||||||
|
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
|
||||||
|
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
|
||||||
|
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
|
||||||
|
|
||||||
|
security:
|
||||||
|
|
||||||
|
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
|
||||||
|
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
|
||||||
|
|
||||||
|
fixes:
|
||||||
|
|
||||||
|
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
|
||||||
|
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
|
||||||
|
|
||||||
|
chore:
|
||||||
|
|
||||||
|
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
|
||||||
|
- Minor code cleanups and comments across controller/model and JS tree logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/11/2025 (v1.9.3)
|
||||||
|
|
||||||
|
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
|
||||||
|
|
||||||
|
- UI / Icons
|
||||||
|
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
|
||||||
|
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
|
||||||
|
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
|
||||||
|
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
|
||||||
|
|
||||||
|
- Folder color & non-empty detection
|
||||||
|
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
|
||||||
|
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
|
||||||
|
|
||||||
|
- FileList strip
|
||||||
|
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
|
||||||
|
|
||||||
|
- Exports & helpers
|
||||||
|
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
|
||||||
|
|
||||||
|
- AppCore
|
||||||
|
- Update file upload DnD relay hook to `#fileList` (id rename).
|
||||||
|
|
||||||
|
- CSS tweaks
|
||||||
|
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
|
||||||
|
|
||||||
|
- CI/CD (release)
|
||||||
|
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
|
||||||
|
|
||||||
|
- Changelog highlights
|
||||||
|
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
|
||||||
|
- Real-time folder color propagation between views.
|
||||||
|
- Folder strip switched to SVG tiles with better layout + context actions.
|
||||||
|
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/10/2025 (v1.9.2)
|
||||||
|
|
||||||
|
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||||
|
|
||||||
|
- New “Upload file(s)” action in Create menu:
|
||||||
|
- Adds `<li id="uploadOption">` to the dropdown.
|
||||||
|
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||||
|
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||||
|
|
||||||
|
- Drag & Drop from file list → Upload:
|
||||||
|
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||||
|
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||||
|
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||||
|
|
||||||
|
- Synthetic drop fallback:
|
||||||
|
- Introduces window.__pendingDropData (cleared after use).
|
||||||
|
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||||
|
|
||||||
|
- Implementation details:
|
||||||
|
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||||
|
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||||
|
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||||
|
|
||||||
|
- UX/Safety:
|
||||||
|
- Defensive checks if modal/card isn’t present.
|
||||||
|
- No backend/API changes; CSRF/auth unchanged.
|
||||||
|
|
||||||
|
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.1)
|
||||||
|
|
||||||
|
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||||
|
|
||||||
|
### Highlights v1.9.1
|
||||||
|
|
||||||
|
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
|
||||||
|
- 📄 Folder icons auto-refresh when contents change (no full page reload).
|
||||||
|
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
|
||||||
|
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
|
||||||
|
|
||||||
|
- feat(colors): add per-folder color customization
|
||||||
|
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
|
||||||
|
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
|
||||||
|
- Read endpoint filters map by ACL::canRead before returning to the user.
|
||||||
|
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
|
||||||
|
|
||||||
|
- feat(ui): color-picker modal with live SVG folder preview
|
||||||
|
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
|
||||||
|
|
||||||
|
- feat(controls): “Color folder” button in Folder Management card
|
||||||
|
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
|
||||||
|
|
||||||
|
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
|
||||||
|
|
||||||
|
- ux(tree): make expansion state more predictable across refreshes
|
||||||
|
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
|
||||||
|
|
||||||
|
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
|
||||||
|
|
||||||
|
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.0)
|
||||||
|
|
||||||
|
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||||
|
|
||||||
|
feat(ui): modern folder tree
|
||||||
|
|
||||||
|
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
|
||||||
|
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
|
||||||
|
- Breadcrumb tweaks (› separators), hover/selected polish
|
||||||
|
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
|
||||||
|
|
||||||
|
feat(api): add /api/folder/isEmpty.php via controller/model
|
||||||
|
|
||||||
|
- public/api/folder/isEmpty.php delegates to FolderController::stats()
|
||||||
|
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
|
||||||
|
- Releases PHP session lock early to avoid parallel-request pileups
|
||||||
|
|
||||||
|
perf: cap concurrent “isEmpty” requests + timeouts
|
||||||
|
|
||||||
|
- Small concurrency limiter + fetch timeouts
|
||||||
|
- In-memory result & inflight caches for fewer network hits
|
||||||
|
|
||||||
|
fix(state): preserve user expand/collapse choices
|
||||||
|
|
||||||
|
- Respect saved folderTreeState; don’t auto-expand unopened nodes
|
||||||
|
- Only show ancestors for visibility when navigating (no unwanted persists)
|
||||||
|
|
||||||
|
security: tighten .htaccess while enabling WebDAV
|
||||||
|
|
||||||
|
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
|
||||||
|
- AcceptPathInfo On; keep path-aware dotfile denial
|
||||||
|
|
||||||
|
refactor: move count logic to model; thin controller action
|
||||||
|
|
||||||
|
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
|
||||||
|
|
||||||
|
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.13)
|
||||||
|
|
||||||
|
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
|
||||||
|
|
||||||
|
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
|
||||||
|
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
|
||||||
|
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
|
||||||
|
- header dock: show dock whenever icons exist (and on collapse); hide when empty
|
||||||
|
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
|
||||||
|
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
|
||||||
|
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
|
||||||
|
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.12)
|
||||||
|
|
||||||
|
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||||
|
|
||||||
|
- auth (public/js/main.js)
|
||||||
|
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||||
|
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||||
|
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||||
|
- Minor cleanup (SW register catch spacing).
|
||||||
|
|
||||||
|
- drag & drop (public/js/dragAndDrop.js)
|
||||||
|
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||||
|
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||||
|
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||||
|
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||||
|
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||||
|
|
||||||
|
- styles (public/css/styles.css)
|
||||||
|
- Header drop zone fills remaining space and right-aligns its icons.
|
||||||
|
|
||||||
|
UX:
|
||||||
|
|
||||||
|
- OIDC button reliably appears when form/basic are disabled.
|
||||||
|
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||||
|
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.11)
|
||||||
|
|
||||||
|
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||||
|
|
||||||
|
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||||
|
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||||
|
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||||
|
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.10)
|
||||||
|
|
||||||
|
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||||
|
|
||||||
|
UI/UX — Media modal
|
||||||
|
|
||||||
|
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||||
|
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||||
|
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||||
|
|
||||||
|
Video/Image controls
|
||||||
|
|
||||||
|
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||||
|
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||||
|
|
||||||
|
Progress badges (list & modal)
|
||||||
|
|
||||||
|
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||||
|
|
||||||
|
Drag & drop
|
||||||
|
|
||||||
|
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||||
|
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||||
|
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||||
|
|
||||||
|
Editor & ONLYOFFICE
|
||||||
|
|
||||||
|
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||||
|
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||||
|
|
||||||
|
Assets & polish
|
||||||
|
|
||||||
|
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||||
|
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||||
|
|
||||||
|
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.9)
|
||||||
|
|
||||||
|
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||||
|
|
||||||
|
- adminPanel.js:
|
||||||
|
- Masked inputs without a saved value now start with data-replace="1".
|
||||||
|
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.8)
|
||||||
|
|
||||||
|
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||||
|
|
||||||
|
**Summary**
|
||||||
|
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||||
|
|
||||||
|
**Backend** changes:
|
||||||
|
|
||||||
|
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot downloads.
|
||||||
|
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
|
||||||
|
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
|
||||||
|
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
|
||||||
|
|
||||||
|
New **Worker**:
|
||||||
|
|
||||||
|
- New `src/cli/zip_worker.php` builds the archive in the background.
|
||||||
|
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the per‑token JSON.
|
||||||
|
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
|
||||||
|
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
|
||||||
|
|
||||||
|
New **API/Status Payload**:
|
||||||
|
|
||||||
|
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
|
||||||
|
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
|
||||||
|
|
||||||
|
**Frontend (UX)** changes:
|
||||||
|
|
||||||
|
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
|
||||||
|
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
|
||||||
|
- Shows determinate **0–98%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
|
||||||
|
- Modal closes just before download; UI resets for the next operation.
|
||||||
|
|
||||||
|
Added **CSS**:
|
||||||
|
|
||||||
|
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
|
||||||
|
|
||||||
|
**Why this closes #60**?
|
||||||
|
|
||||||
|
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
|
||||||
|
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
|
||||||
|
- Users get continuous, truthful feedback for large multi‑GB archives.
|
||||||
|
|
||||||
|
Additional **Notes**
|
||||||
|
|
||||||
|
- Download tokens are **one‑shot** and are deleted after the GET completes.
|
||||||
|
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/5/2025 (v1.8.7)
|
## Changes 11/5/2025 (v1.8.7)
|
||||||
|
|
||||||
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
||||||
|
|||||||
541
README.md
@@ -10,483 +10,192 @@
|
|||||||
[](https://github.com/sponsors/error311)
|
[](https://github.com/sponsors/error311)
|
||||||
[](https://ko-fi.com/error311)
|
[](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.
|

|
||||||
|
|
||||||
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 you’re on ≤1.4.x, please upgrade.
|
|
||||||
|
|
||||||
**10/25/2025 Video demo:**
|
|
||||||
|
|
||||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
|
||||||
|
|
||||||
**Dark mode:**
|
|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
[](https://demo.filerise.net)
|
FileRise turns a folder on your server into a **web‑based 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
|
||||||
|
- Multi‑file/folder drag‑and‑drop uploads
|
||||||
|
- Move / copy / rename / delete / extract ZIP
|
||||||
|
- Public share links (optionally password‑protected & expiring)
|
||||||
|
- Tagging and search by name, tag, uploader, and content
|
||||||
|
- Trash with restore/purge
|
||||||
|
- Inline previews (images, audio, video, PDF) and a built‑in 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 you’ll be guided through creating the **initial admin user**.
|
||||||
|
|
||||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
**More Docker options (Unraid, docker‑compose, 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 server’s 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, you’ll 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 bare‑metal 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 + PHP‑FPM)
|
||||||
- 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**
|
||||||
|
|
||||||
```bash
|
1. Clone or download FileRise into your web root:
|
||||||
git clone https://github.com/error311/FileRise.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
```bash
|
||||||
|
git clone https://github.com/error311/FileRise.git
|
||||||
**Composer (if applicable)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install
|
|
||||||
```
|
|
||||||
|
|
||||||
**Folders & Permissions**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
- `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; you’ll be prompted to create the Admin user on first load.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3) Admins
|
|
||||||
|
|
||||||
> **Admins in ACL UI**
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Unraid
|
|
||||||
|
|
||||||
- Install from **Community Apps** → search **FileRise**.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 (2–3 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**
|
2. Create data directories and set permissions:
|
||||||
|
|
||||||
```nginx
|
```bash
|
||||||
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;
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes**
|
3. (Optional) Install PHP dependencies with Composer:
|
||||||
- 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.
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
||||||
|
|
||||||
|
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ / Troubleshooting
|
## 4. WebDAV & ONLYOFFICE (optional)
|
||||||
|
|
||||||
- **ONLYOFFICE editor won’t 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.
|
### WebDAV
|
||||||
- **“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 PHP’s `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.
|
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
|
||||||
|
|
||||||
- **How to enable HTTPS?** FileRise doesn’t 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.
|
- **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.
|
||||||
|
|
||||||
- **Changing Admin or resetting password:** Admin can change any user’s 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.
|
WebDAV operations honor the same ACLs as the web UI.
|
||||||
|
|
||||||
- **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.
|
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||||
|
|
||||||
- **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 you’re 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.
|
|
||||||
|
|
||||||
[](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 view‑only).
|
||||||
|
|
||||||
- **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 Content‑Security‑Policy 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 Unraid‑specific setup and tuning.
|
||||||
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
|
- 🌍 **Reddit / self‑hosting 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)
|
||||||
|
- ☕ [Ko‑fi](https://ko-fi.com/error311)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. License & third‑party code
|
||||||
|
|
||||||
|
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
It bundles a small set of well‑known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||||
|
All third‑party 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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,4 +254,59 @@ 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 didn’t 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
} catch (Throwable $e) { /* ignore */ }
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
|
||||||
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);
|
|
||||||
@@ -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)) {
|
|
||||||
|
try {
|
||||||
|
$ctrl = new AclAdminController();
|
||||||
|
$res = $ctrl->saveUserGrantsPayload($in ?? []);
|
||||||
|
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Invalid JSON']);
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
exit;
|
} catch (Throwable $e) {
|
||||||
}
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||||
// ---- 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 {
|
|
||||||
$res = ACL::applyUserGrantsAtomic($user, $grants);
|
|
||||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
|
||||||
exit;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
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}]}']);
|
|
||||||
8
public/api/admin/installProBundle.php
Normal 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();
|
||||||
8
public/api/admin/setLicense.php
Normal 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();
|
||||||
24
public/api/file/downloadZipFile.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/downloadZipFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/downloadZipFile.php",
|
||||||
|
* summary="Download a finished ZIP by token",
|
||||||
|
* description="Streams the zip once; token is one-shot.",
|
||||||
|
* operationId="downloadZipFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
|
||||||
|
* @OA\Response(response=200, description="ZIP stream"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->downloadZipFile();
|
||||||
23
public/api/file/zipStatus.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/zipStatus.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/zipStatus.php",
|
||||||
|
* summary="Check status of a background ZIP build",
|
||||||
|
* description="Returns status for the authenticated user's token.",
|
||||||
|
* operationId="zipStatus",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Response(response=200, description="Status payload"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->zipStatus();
|
||||||
@@ -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);
|
|
||||||
17
public/api/folder/getFolderColors.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->getFolderColors(); // echoes JSON + status codes
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('getFolderColors failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
28
public/api/folder/isEmpty.php
Normal 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);
|
||||||
31
public/api/folder/listChildren.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||||
|
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||||
|
];
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
|
|
||||||
|
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
|
||||||
|
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
|
||||||
|
|
||||||
|
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
|
||||||
|
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||||
17
public/api/folder/saveFolderColor.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('saveFolderColor failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
32
public/api/pro/groups/list.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
51
public/api/pro/groups/save.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
27
public/api/pro/portals/get.php
Normal 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);
|
||||||
|
}
|
||||||
32
public/api/pro/portals/list.php
Normal 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);
|
||||||
|
}
|
||||||
108
public/api/pro/portals/publicMeta.php
Normal 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 you’re 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,
|
||||||
|
]);
|
||||||
51
public/api/pro/portals/save.php
Normal 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);
|
||||||
|
}
|
||||||
64
public/api/pro/portals/submissions.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
91
public/api/pro/portals/submitForm.php
Normal 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);
|
||||||
|
}
|
||||||
28
public/api/pro/uploadBrandLogo.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,17 +3,24 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||||
<style id="pretheme-css">
|
<style id="pretheme-css">
|
||||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||||
</style>
|
</style>
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||||
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Critical CSS -->
|
<!-- Critical CSS -->
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
@@ -54,7 +61,27 @@
|
|||||||
<h1>FileRise</h1>
|
<h1>FileRise</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
<!-- Zoom controls FIRST on the right -->
|
||||||
|
<div class="header-zoom-controls">
|
||||||
|
<!-- Left stack: + / - -->
|
||||||
|
<div class="zoom-vertical">
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="in" title="Zoom in">
|
||||||
|
<span class="material-icons">add</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="out" title="Zoom out">
|
||||||
|
<span class="material-icons">remove</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right stack: 100% / reset -->
|
||||||
|
<div class="zoom-meta">
|
||||||
|
<span id="zoomDisplay" class="zoom-display">100%</span>
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="reset" title="Reset zoom">
|
||||||
|
<span class="material-icons">refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-buttons-wrapper" style="display: flex; align-items: center;">
|
||||||
|
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
@@ -105,6 +132,7 @@
|
|||||||
<!-- Custom Toast Container -->
|
<!-- Custom Toast Container -->
|
||||||
<div id="customToast"></div>
|
<div id="customToast"></div>
|
||||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||||
|
<div id="appZoomShell">
|
||||||
<main id="main" hidden>
|
<main id="main" hidden>
|
||||||
<div class="row mt-4" id="loginForm">
|
<div class="row mt-4" id="loginForm">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -245,6 +273,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 +298,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 can’t be opened.</li>
|
||||||
|
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
|
||||||
|
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
|
||||||
|
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
|
||||||
|
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
|
||||||
|
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
|
||||||
|
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +386,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 -->
|
||||||
@@ -377,7 +422,7 @@
|
|||||||
</div> <!-- end container-fluid -->
|
</div> <!-- end container-fluid -->
|
||||||
</div> <!-- end mainColumn -->
|
</div> <!-- end mainColumn -->
|
||||||
</div> <!-- end main-wrapper -->
|
</div> <!-- end main-wrapper -->
|
||||||
|
</div>
|
||||||
<!-- Download Progress Modal -->
|
<!-- Download Progress Modal -->
|
||||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
@@ -453,6 +498,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 +549,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="uploadModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:900px;width:92vw;">
|
||||||
|
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h3 style="margin:0;">Upload</h3>
|
||||||
|
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- we will MOVE #uploadCard into here while open -->
|
||||||
|
<div id="uploadModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
302
public/js/adminPanelStyles.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -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,26 @@ 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';
|
||||||
|
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (zoomWrap) {
|
||||||
|
const hideZoom = localStorage.getItem('hideZoomControls') === 'true';
|
||||||
|
if (hideZoom) {
|
||||||
|
zoomWrap.style.display = 'none';
|
||||||
|
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||||
|
} else {
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always load zoom.js once app is running
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`).catch(err => {
|
||||||
|
console.warn('[zoom] failed to load zoom.js', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load public site config early (safe subset)
|
// Load public site config early (safe subset)
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
@@ -84,27 +117,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();
|
||||||
@@ -132,6 +194,25 @@ export function initializeApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Zoom controls: load only for logged-in app ----
|
||||||
|
(function loadZoomControls() {
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (!zoomWrap) return;
|
||||||
|
|
||||||
|
// show container (keep CSS default = hidden)
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.style.alignItems = 'center';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`)
|
||||||
|
.catch(err => console.warn('[zoom] failed to load:', err));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[zoom] load error:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
LOGOUT (shared)
|
LOGOUT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
|
|||||||
@@ -34,18 +34,19 @@ 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 &&
|
||||||
/please log in/i.test(String(msgKeyOrText)))) {
|
(msgKeyOrText === 'please_log_in_to_continue' ||
|
||||||
|
/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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don’t nag during pending TOTP, as you already had
|
// Don’t 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() isn’t 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();
|
||||||
|
|||||||
@@ -195,8 +195,7 @@ 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: 600px;
|
||||||
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;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -352,30 +351,115 @@ 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');
|
|
||||||
dispLegend.textContent = t('display');
|
const dispLegend = document.createElement('legend');
|
||||||
dispFs.appendChild(dispLegend);
|
dispLegend.textContent = t('display');
|
||||||
const dispLabel = document.createElement('label');
|
dispFs.appendChild(dispLegend);
|
||||||
dispLabel.style.cursor = 'pointer';
|
|
||||||
const dispCb = document.createElement('input');
|
// 1) Show folder strip above list
|
||||||
dispCb.type = 'checkbox';
|
const stripLabel = document.createElement('label');
|
||||||
dispCb.id = 'showFoldersInList';
|
stripLabel.style.cursor = 'pointer';
|
||||||
dispCb.style.verticalAlign = 'middle';
|
stripLabel.style.display = 'block';
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
stripLabel.style.marginBottom = '4px';
|
||||||
dispCb.checked = stored === null ? true : stored === 'true';
|
|
||||||
dispLabel.appendChild(dispCb);
|
const stripCb = document.createElement('input');
|
||||||
dispLabel.append(` ${t('show_folders_above_files')}`);
|
stripCb.type = 'checkbox';
|
||||||
dispFs.appendChild(dispLabel);
|
stripCb.id = 'showFoldersInList';
|
||||||
content.appendChild(dispFs);
|
stripCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||||
|
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);
|
||||||
|
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||||
|
dispFs.appendChild(inlineLabel);
|
||||||
|
|
||||||
|
// 3) Hide header zoom controls
|
||||||
|
const zoomLabel = document.createElement('label');
|
||||||
|
zoomLabel.style.cursor = 'pointer';
|
||||||
|
zoomLabel.style.display = 'block';
|
||||||
|
zoomLabel.style.marginTop = '4px';
|
||||||
|
|
||||||
|
const zoomCb = document.createElement('input');
|
||||||
|
zoomCb.type = 'checkbox';
|
||||||
|
zoomCb.id = 'hideHeaderZoomControls';
|
||||||
|
zoomCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedZoom = localStorage.getItem('hideZoomControls');
|
||||||
|
zoomCb.checked = storedZoom === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomLabel.appendChild(zoomCb);
|
||||||
|
zoomLabel.append(` ${t('hide_header_zoom_controls') || 'Hide zoom controls in header'}`);
|
||||||
|
dispFs.appendChild(zoomLabel);
|
||||||
|
|
||||||
|
content.appendChild(dispFs);
|
||||||
|
|
||||||
|
// Handlers: toggle + refresh list
|
||||||
|
stripCb.addEventListener('change', () => {
|
||||||
|
window.showFoldersInList = stripCb.checked;
|
||||||
|
localStorage.setItem('showFoldersInList', stripCb.checked);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEW: zoom hide/show handler
|
||||||
|
zoomCb.addEventListener('change', () => {
|
||||||
|
const hideZoom = zoomCb.checked;
|
||||||
|
localStorage.setItem('hideZoomControls', hideZoom ? 'true' : 'false');
|
||||||
|
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (!zoomWrap) return;
|
||||||
|
|
||||||
|
if (hideZoom) {
|
||||||
|
zoomWrap.style.display = 'none';
|
||||||
|
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||||
|
} else {
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
dispCb.addEventListener('change', () => {
|
inlineCb.addEventListener('change', () => {
|
||||||
window.showFoldersInList = dispCb.checked;
|
window.showInlineFolders = inlineCb.checked;
|
||||||
localStorage.setItem('showFoldersInList', dispCb.checked);
|
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||||
// re‐load the entire file list (and strip) in one go:
|
if (typeof window.loadFileList === 'function') {
|
||||||
loadFileList(window.currentFolder);
|
window.loadFileList(window.currentFolder || 'root');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// wire up image‐input change
|
// wire up image‐input change
|
||||||
@@ -426,6 +510,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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function handleDeleteSelected(e) {
|
export function handleDeleteSelected(e) {
|
||||||
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
|
|||||||
showToast("no_files_selected");
|
showToast("no_files_selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||||
const count = window.filesToDelete.length;
|
const count = window.filesToDelete.length;
|
||||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||||
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Upload modal "portal" support ---
|
||||||
|
let _uploadCardSentinel = null;
|
||||||
|
|
||||||
|
export function openUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const body = document.getElementById('uploadModalBody');
|
||||||
|
const card = document.getElementById('uploadCard'); // <-- your existing card
|
||||||
|
window.openUploadModal = openUploadModal;
|
||||||
|
window.__pendingDropData = null;
|
||||||
|
if (!modal || !body || !card) {
|
||||||
|
console.warn('Upload modal or upload card not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a hidden sentinel so we can put the card back in place later
|
||||||
|
if (!_uploadCardSentinel) {
|
||||||
|
_uploadCardSentinel = document.createElement('div');
|
||||||
|
_uploadCardSentinel.id = 'uploadCardSentinel';
|
||||||
|
_uploadCardSentinel.style.display = 'none';
|
||||||
|
card.parentNode.insertBefore(_uploadCardSentinel, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the actual card node into the modal (keeps all existing listeners)
|
||||||
|
body.appendChild(card);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Focus the chooser for quick keyboard flow
|
||||||
|
setTimeout(() => {
|
||||||
|
const chooseBtn = document.getElementById('customChooseBtn');
|
||||||
|
if (chooseBtn) chooseBtn.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const card = document.getElementById('uploadCard');
|
||||||
|
|
||||||
|
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
|
||||||
|
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
|
||||||
|
}
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||||
if (cancelDelete) {
|
if (cancelDelete) {
|
||||||
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files deleted successfully!");
|
showToast("Selected files deleted successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not delete files"));
|
showToast("Error: " + (data.error || "Could not delete files"));
|
||||||
}
|
}
|
||||||
@@ -119,7 +166,7 @@ export async function handleCreateFile(e) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type':'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': window.csrfToken
|
'X-CSRF-Token': window.csrfToken
|
||||||
},
|
},
|
||||||
// ⚠️ must send `name`, not `filename`
|
// ⚠️ must send `name`, not `filename`
|
||||||
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
|
|||||||
if (!js.success) throw new Error(js.error);
|
if (!js.success) throw new Error(js.error);
|
||||||
showToast(t('file_created'));
|
showToast(t('file_created'));
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +187,7 @@ export async function handleCreateFile(e) {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const cancel = document.getElementById('cancelCreateFile');
|
const cancel = document.getElementById('cancelCreateFile');
|
||||||
const confirm = document.getElementById('confirmCreateFile');
|
const confirm = document.getElementById('confirmCreateFile');
|
||||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||||
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +313,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
if (cancelCreate) {
|
if (cancelCreate) {
|
||||||
cancelCreate.addEventListener('click', () => {
|
cancelCreate.addEventListener('click', () => {
|
||||||
document.getElementById('createFileModal').style.display = 'none';
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
@@ -300,12 +348,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
showToast(t('file_created_successfully'));
|
showToast(t('file_created_successfully'));
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
@@ -321,63 +370,187 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
confirmZipBtn.addEventListener("click", async () => {
|
confirmZipBtn.addEventListener("click", async () => {
|
||||||
// a) Validate ZIP filename
|
// a) Validate ZIP filename
|
||||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||||
if (!zipName) {
|
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
|
||||||
showToast("Please enter a name for the zip file.");
|
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
|
||||||
zipName += ".zip";
|
|
||||||
}
|
|
||||||
|
|
||||||
// b) Hide the name‐input modal, show the spinner modal
|
// b) Hide the name‐input modal, show the progress modal
|
||||||
zipNameModal.style.display = "none";
|
zipNameModal.style.display = "none";
|
||||||
progressModal.style.display = "block";
|
progressModal.style.display = "block";
|
||||||
|
|
||||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
// c) Title text (optional)
|
||||||
const titleEl = document.getElementById("downloadProgressTitle");
|
const titleEl = document.getElementById("downloadProgressTitle");
|
||||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
try {
|
// d) Queue the job
|
||||||
// d) POST and await the ZIP blob
|
const res = await fetch("/api/file/downloadZip.php", {
|
||||||
const res = await fetch("/api/file/downloadZip.php", {
|
method: "POST",
|
||||||
method: "POST",
|
credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
|
||||||
"Content-Type": "application/json",
|
});
|
||||||
"X-CSRF-Token": window.csrfToken
|
const jsr = await res.json().catch(() => ({}));
|
||||||
},
|
if (!res.ok || !jsr.ok) {
|
||||||
body: JSON.stringify({
|
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
|
||||||
folder: window.currentFolder || "root",
|
throw new Error(msg);
|
||||||
files: window.filesToDownload
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(txt || `Status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
if (!blob || blob.size === 0) {
|
|
||||||
throw new Error("Received empty ZIP file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// e) Hand off to the browser’s download manager
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = zipName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error downloading ZIP:", err);
|
|
||||||
showToast("Error: " + err.message);
|
|
||||||
} finally {
|
|
||||||
// f) Always hide spinner modal
|
|
||||||
progressModal.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
const token = jsr.token;
|
||||||
|
const statusUrl = jsr.statusUrl;
|
||||||
|
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
|
||||||
|
|
||||||
|
// Ensure a progress UI exists in the modal
|
||||||
|
function ensureZipProgressUI() {
|
||||||
|
const modalEl = document.getElementById("downloadProgressModal");
|
||||||
|
if (!modalEl) {
|
||||||
|
// really shouldn't happen, but fall back to body
|
||||||
|
console.warn("downloadProgressModal not found; falling back to document.body");
|
||||||
|
}
|
||||||
|
// Prefer a dedicated content node inside the modal
|
||||||
|
let host =
|
||||||
|
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-content")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".content")) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// If no suitable container, create one inside the modal
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.id = "downloadProgressContent";
|
||||||
|
(modalEl || document.body).appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: ensure/move an element with given id into host
|
||||||
|
function ensureInHost(id, tag, init) {
|
||||||
|
let el = document.getElementById(id);
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tag);
|
||||||
|
el.id = id;
|
||||||
|
if (typeof init === "function") init(el);
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
|
||||||
|
el.style.marginBottom = "8px";
|
||||||
|
el.textContent = "Preparing…";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar (native <progress>)
|
||||||
|
const bar = (function () {
|
||||||
|
let el = document.getElementById("downloadProgressBar");
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("progress");
|
||||||
|
el.id = "downloadProgressBar";
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
el.max = 100;
|
||||||
|
el.value = 0;
|
||||||
|
el.style.display = ""; // override any inline display:none
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.height = "1.1em";
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Text line
|
||||||
|
const text = ensureInHost("downloadProgressText", "div", (el) => {
|
||||||
|
el.style.marginTop = "8px";
|
||||||
|
el.style.fontSize = "0.9rem";
|
||||||
|
el.style.whiteSpace = "nowrap";
|
||||||
|
el.style.overflow = "hidden";
|
||||||
|
el.style.textOverflow = "ellipsis";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional spinner hider
|
||||||
|
const hideSpinner = () => {
|
||||||
|
const sp = document.getElementById("downloadSpinner");
|
||||||
|
if (sp) sp.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
return { bar, text, title, hideSpinner };
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanBytes(n) {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
|
||||||
|
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||||
|
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
||||||
|
}
|
||||||
|
function mmss(sec) {
|
||||||
|
sec = Math.max(0, sec | 0);
|
||||||
|
const m = (sec / 60) | 0, s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui = ensureZipProgressUI();
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
// e) Poll until ready
|
||||||
|
while (true) {
|
||||||
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
|
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
|
||||||
|
credentials: "include", cache: "no-store",
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
if (s.error) throw new Error(s.error);
|
||||||
|
if (ui.title) ui.title.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
|
// --- RENDER PROGRESS ---
|
||||||
|
if (typeof s.pct === "number" && ui.bar && ui.text) {
|
||||||
|
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
|
||||||
|
ui.hideSpinner && ui.hideSpinner();
|
||||||
|
const filesDone = s.filesDone ?? 0;
|
||||||
|
const filesTotal = s.filesTotal ?? 0;
|
||||||
|
const bytesDone = s.bytesDone ?? 0;
|
||||||
|
const bytesTotal = s.bytesTotal ?? 0;
|
||||||
|
|
||||||
|
// Determinate 0–98% while enumerating
|
||||||
|
const pct = Math.max(0, Math.min(98, s.pct | 0));
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
|
||||||
|
ui.bar.value = pct;
|
||||||
|
ui.text.textContent =
|
||||||
|
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
|
||||||
|
} else {
|
||||||
|
// FINALIZING: keep progress at 100% and show timer + selected totals
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
|
||||||
|
ui.bar.value = 100; // lock at 100 during finalizing
|
||||||
|
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
|
||||||
|
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
|
||||||
|
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
|
||||||
|
ui.text.textContent = `Finalizing… ${mmss(since)} — ${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
|
||||||
|
}
|
||||||
|
} else if (ui.text) {
|
||||||
|
ui.text.textContent = "Still preparing…";
|
||||||
|
}
|
||||||
|
// --- /RENDER ---
|
||||||
|
|
||||||
|
if (s.ready) {
|
||||||
|
// Snap to 100 and close modal just before download
|
||||||
|
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
|
||||||
|
progressModal.style.display = "none";
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// f) Trigger download
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = zipName;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
// g) Reset for next time
|
||||||
|
if (ui.bar) ui.bar.value = 0;
|
||||||
|
if (ui.text) ui.text.textContent = "";
|
||||||
|
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -509,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files copied successfully!", 5000);
|
showToast("Selected files copied successfully!", 5000);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||||
}
|
}
|
||||||
@@ -561,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files moved successfully!");
|
showToast("Selected files moved successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not move files"));
|
showToast("Error: " + (data.error || "Could not move files"));
|
||||||
}
|
}
|
||||||
@@ -694,10 +870,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const btn = document.getElementById('createBtn');
|
const btn = document.getElementById('createBtn');
|
||||||
const menu = document.getElementById('createMenu');
|
const menu = document.getElementById('createMenu');
|
||||||
const fileOpt = document.getElementById('createFileOption');
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
const folderOpt= document.getElementById('createFolderOption');
|
const folderOpt = document.getElementById('createFolderOption');
|
||||||
|
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||||
|
|
||||||
// Toggle dropdown on click
|
// Toggle dropdown on click
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -722,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
menu.style.display = 'none';
|
menu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
if (uploadOpt) {
|
||||||
|
uploadOpt.addEventListener('click', () => {
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
openUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close buttons / backdrop
|
||||||
|
const upModal = document.getElementById('uploadModal');
|
||||||
|
const closeX = document.getElementById('closeUploadModal');
|
||||||
|
|
||||||
|
if (closeX) closeX.addEventListener('click', closeUploadModal);
|
||||||
|
|
||||||
|
// click outside content to close
|
||||||
|
if (upModal) {
|
||||||
|
upModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === upModal) closeUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
|
||||||
|
closeUploadModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -2,124 +2,163 @@
|
|||||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function fileDragStartHandler(event) {
|
/* ---------------- helpers ---------------- */
|
||||||
const row = event.currentTarget;
|
function getRowEl(el) {
|
||||||
let fileNames = [];
|
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||||
|
}
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
function getNameFromAny(el) {
|
||||||
if (selectedCheckboxes.length > 1) {
|
const row = getRowEl(el);
|
||||||
selectedCheckboxes.forEach(chk => {
|
if (!row) return null;
|
||||||
const parentRow = chk.closest("tr");
|
// 1) canonical
|
||||||
if (parentRow) {
|
const n = row.getAttribute('data-file-name');
|
||||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
if (n) return n;
|
||||||
if (cell) {
|
// 2) filename-only span
|
||||||
let rawName = cell.textContent.trim();
|
const span = row.querySelector('.filename-text');
|
||||||
const tagContainer = cell.querySelector(".tag-badges");
|
if (span) return span.textContent.trim();
|
||||||
if (tagContainer) {
|
return null;
|
||||||
const tagText = tagContainer.innerText.trim();
|
}
|
||||||
if (rawName.endsWith(tagText)) {
|
function getSelectedFileNames() {
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||||
}
|
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||||
}
|
// de-dup just in case
|
||||||
fileNames.push(rawName);
|
return Array.from(new Set(names));
|
||||||
}
|
}
|
||||||
}
|
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||||
});
|
const wrap = document.createElement('div');
|
||||||
} else {
|
Object.assign(wrap.style, {
|
||||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
display: 'inline-flex',
|
||||||
if (fileNameCell) {
|
maxWidth: '420px',
|
||||||
let rawName = fileNameCell.textContent.trim();
|
padding: '6px 10px',
|
||||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
backgroundColor: '#333',
|
||||||
if (tagContainer) {
|
color: '#fff',
|
||||||
const tagText = tagContainer.innerText.trim();
|
border: '1px solid #555',
|
||||||
if (rawName.endsWith(tagText)) {
|
borderRadius: '6px',
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
alignItems: 'center',
|
||||||
}
|
gap: '6px',
|
||||||
}
|
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||||
fileNames.push(rawName);
|
fontSize: '12px',
|
||||||
}
|
pointerEvents: 'none'
|
||||||
}
|
});
|
||||||
|
const icon = document.createElement('span');
|
||||||
if (fileNames.length === 0) return;
|
icon.className = 'material-icons';
|
||||||
|
icon.textContent = iconName;
|
||||||
const dragData = fileNames.length === 1
|
const label = document.createElement('span');
|
||||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
// trim long single-name labels
|
||||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
const txt = String(labelText || '');
|
||||||
|
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
wrap.appendChild(icon);
|
||||||
|
wrap.appendChild(label);
|
||||||
let dragImage = document.createElement("div");
|
document.body.appendChild(wrap);
|
||||||
dragImage.style.display = "inline-flex";
|
return wrap;
|
||||||
dragImage.style.width = "auto";
|
|
||||||
dragImage.style.maxWidth = "fit-content";
|
|
||||||
dragImage.style.padding = "6px 10px";
|
|
||||||
dragImage.style.backgroundColor = "#333";
|
|
||||||
dragImage.style.color = "#fff";
|
|
||||||
dragImage.style.border = "1px solid #555";
|
|
||||||
dragImage.style.borderRadius = "4px";
|
|
||||||
dragImage.style.alignItems = "center";
|
|
||||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "material-icons";
|
|
||||||
icon.textContent = "insert_drive_file";
|
|
||||||
icon.style.marginRight = "4px";
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
|
||||||
dragImage.appendChild(icon);
|
|
||||||
dragImage.appendChild(label);
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage);
|
|
||||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(dragImage);
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- drag start (rows/cards) ---------------- */
|
||||||
|
export function fileDragStartHandler(event) {
|
||||||
|
const row = getRowEl(event.currentTarget);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
// Use current selection if present; otherwise drag just this row’s file
|
||||||
|
let names = getSelectedFileNames();
|
||||||
|
if (names.length === 0) {
|
||||||
|
const single = getNameFromAny(row);
|
||||||
|
if (single) names = [single];
|
||||||
|
}
|
||||||
|
if (names.length === 0) return;
|
||||||
|
|
||||||
|
const sourceFolder = window.currentFolder || 'root';
|
||||||
|
const payload = { files: names, sourceFolder };
|
||||||
|
|
||||||
|
// primary payload
|
||||||
|
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||||
|
// fallback (lets some environments read something human)
|
||||||
|
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||||
|
|
||||||
|
// nicer drag image
|
||||||
|
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||||
|
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||||
|
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||||
|
// clean up the ghost as soon as the browser has captured it
|
||||||
|
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- folder targets ---------------- */
|
||||||
export function folderDragOverHandler(event) {
|
export function folderDragOverHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.add("drop-hover");
|
event.currentTarget.classList.add('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDragLeaveHandler(event) {
|
export function folderDragLeaveHandler(event) {
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDropHandler(event) {
|
export async function folderDropHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
|
||||||
let dragData;
|
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||||
|
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||||
|
|| 'root';
|
||||||
|
|
||||||
|
// parse drag payload
|
||||||
|
let dragData = null;
|
||||||
try {
|
try {
|
||||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||||
} catch (e) {
|
dragData = JSON.parse(raw);
|
||||||
console.error("Invalid drag data");
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!dragData) {
|
||||||
|
showToast('Invalid drag data.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragData || !dragData.fileName) return;
|
|
||||||
fetch("/api/file/moveFiles.php", {
|
// normalize names
|
||||||
method: "POST",
|
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||||
credentials: "include",
|
: dragData.fileName ? [dragData.fileName]
|
||||||
headers: {
|
: [];
|
||||||
"Content-Type": "application/json",
|
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
|
||||||
},
|
if (names.length === 0) {
|
||||||
body: JSON.stringify({
|
showToast('No files to move.');
|
||||||
source: dragData.sourceFolder,
|
return;
|
||||||
files: [dragData.fileName],
|
}
|
||||||
destination: dropFolder
|
|
||||||
})
|
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||||
})
|
if (dropFolder === sourceFolder) {
|
||||||
.then(response => response.json())
|
showToast('Source and destination are the same.');
|
||||||
.then(data => {
|
return;
|
||||||
if (data.success) {
|
}
|
||||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
|
||||||
loadFileList(dragData.sourceFolder);
|
// POST move
|
||||||
} else {
|
try {
|
||||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
const res = await fetch('/api/file/moveFiles.php', {
|
||||||
}
|
method: 'POST',
|
||||||
})
|
credentials: 'include',
|
||||||
.catch(error => {
|
headers: {
|
||||||
console.error("Error moving file via drop:", error);
|
'Content-Type': 'application/json',
|
||||||
showToast("Error moving file.");
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: sourceFolder,
|
||||||
|
files: names,
|
||||||
|
destination: dropFolder
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok && data && data.success) {
|
||||||
|
const msg = (names.length === 1)
|
||||||
|
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||||
|
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||||
|
showToast(msg);
|
||||||
|
// Refresh whatever view the user is currently looking at
|
||||||
|
loadFileList(window.currentFolder || sourceFolder);
|
||||||
|
} else {
|
||||||
|
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||||
|
showToast('Error moving file(s): ' + err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error moving file(s):', e);
|
||||||
|
showToast('Error moving file(s).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
|
|||||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||||
|
|
||||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
|
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||||
|
|
||||||
async function fetchOnlyOfficeCapsOnce() {
|
async function fetchOnlyOfficeCapsOnce() {
|
||||||
if (__ooCaps.fetched) return __ooCaps;
|
if (__ooCaps.fetched) return __ooCaps;
|
||||||
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
|
|||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
__ooCaps.enabled = !!j.enabled;
|
__ooCaps.enabled = !!j.enabled;
|
||||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||||
|
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||||
}
|
}
|
||||||
} catch { /* ignore; keep defaults */ }
|
} catch { /* ignore; keep defaults */ }
|
||||||
__ooCaps.fetched = true;
|
__ooCaps.fetched = true;
|
||||||
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(fileName) {
|
|||||||
|
|
||||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||||
|
|
||||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
// ---- script/css single-load with timeout guards ----
|
||||||
let src =
|
|
||||||
srcFromConfig ||
|
|
||||||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
|
|
||||||
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
|
|
||||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
|
||||||
await loadScriptOnce(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openOnlyOffice(fileName, folder) {
|
|
||||||
let editor; // make visible to the whole function
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
|
||||||
const resp = await fetch(url, { credentials: 'include' });
|
|
||||||
|
|
||||||
const text = await resp.text();
|
|
||||||
let cfg;
|
|
||||||
try { cfg = JSON.parse(text); } catch {
|
|
||||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
|
||||||
|
|
||||||
// Must be absolute
|
|
||||||
const docUrl = cfg?.document?.url;
|
|
||||||
const cbUrl = cfg?.editorConfig?.callbackUrl;
|
|
||||||
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
|
|
||||||
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load DocsAPI if needed
|
|
||||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
|
||||||
|
|
||||||
// Modal
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.id = 'ooEditorModal';
|
|
||||||
modal.classList.add('modal', 'editor-modal');
|
|
||||||
modal.setAttribute('tabindex', '-1');
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="editor-header">
|
|
||||||
<h3 class="editor-title">
|
|
||||||
${t("editing")}: ${escapeHTML(fileName)}
|
|
||||||
</h3>
|
|
||||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="editor-body" style="flex:1;min-height:200px">
|
|
||||||
<div id="oo-editor" style="width:100%;height:100%"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
modal.style.display = 'block';
|
|
||||||
modal.focus();
|
|
||||||
|
|
||||||
// We’ll fill this after wiring the toggle, so destroy() can unhook it
|
|
||||||
let removeThemeListener = () => {};
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
try { editor?.destroyEditor?.(); } catch {}
|
|
||||||
try { removeThemeListener(); } catch {}
|
|
||||||
try { modal.remove(); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
|
|
||||||
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
|
|
||||||
|
|
||||||
// Let DS request closing
|
|
||||||
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
|
|
||||||
|
|
||||||
// Initial theme
|
|
||||||
const isDark =
|
|
||||||
document.documentElement.classList.contains('dark-mode') ||
|
|
||||||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
|
||||||
|
|
||||||
cfg.editorConfig = cfg.editorConfig || {};
|
|
||||||
cfg.editorConfig.customization = Object.assign(
|
|
||||||
{},
|
|
||||||
cfg.editorConfig.customization,
|
|
||||||
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
|
|
||||||
);
|
|
||||||
|
|
||||||
// Launch editor
|
|
||||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
|
||||||
|
|
||||||
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
|
|
||||||
const darkToggle = document.getElementById('darkModeToggle');
|
|
||||||
const onDarkToggle = () => {
|
|
||||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
|
||||||
if (editor && typeof editor.setTheme === 'function') {
|
|
||||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (darkToggle) {
|
|
||||||
darkToggle.addEventListener('click', onDarkToggle);
|
|
||||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ONLYOFFICE] failed to open:', e);
|
|
||||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
const _loadedScripts = new Set();
|
const _loadedScripts = new Set();
|
||||||
const _loadedCss = new Set();
|
const _loadedCss = new Set();
|
||||||
let _corePromise = null;
|
let _corePromise = null;
|
||||||
|
|
||||||
function loadScriptOnce(url) {
|
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (_loadedScripts.has(url)) return resolve();
|
if (_loadedScripts.has(url)) return resolve();
|
||||||
const s = document.createElement("script");
|
const s = document.createElement("script");
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { s.remove(); } catch { }
|
||||||
|
reject(new Error(`Timeout loading: ${url}`));
|
||||||
|
}, timeoutMs);
|
||||||
s.src = url;
|
s.src = url;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,7 +143,6 @@ async function ensureCore() {
|
|||||||
async function loadSingleMode(name) {
|
async function loadSingleMode(name) {
|
||||||
const rel = MODE_URL[name];
|
const rel = MODE_URL[name];
|
||||||
if (!rel) return;
|
if (!rel) return;
|
||||||
// prepend base if needed
|
|
||||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||||
await loadScriptOnce(url);
|
await loadScriptOnce(url);
|
||||||
}
|
}
|
||||||
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public helper for callers (we keep your existing function name in use):
|
// Public helper for callers (we keep your existing function name in use):
|
||||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||||
// ==== /CodeMirror lazy loader ===============================================
|
// ==== /CodeMirror lazy loader ===============================================
|
||||||
|
|
||||||
|
// ---- OO preconnect / prewarm ----
|
||||||
|
function injectOOPreconnect(origin) {
|
||||||
|
try {
|
||||||
|
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||||
|
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||||
|
document.head.appendChild(make('dns-prefetch'));
|
||||||
|
document.head.appendChild(make('preconnect'));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||||
|
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||||
|
let src = srcFromConfig;
|
||||||
|
if (!src) {
|
||||||
|
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||||
|
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||||
|
} else {
|
||||||
|
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||||
|
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||||
|
try {
|
||||||
|
console.time('oo:api.js');
|
||||||
|
await loadScriptOnce(src);
|
||||||
|
} catch (e) {
|
||||||
|
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||||
|
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.timeEnd('oo:api.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||||
|
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||||
|
const OO_WARM_MS = 300;
|
||||||
|
|
||||||
|
function ensureOoModalCss() {
|
||||||
|
const prev = document.getElementById('ooEditorModalCss');
|
||||||
|
if (prev) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'ooEditorModalCss';
|
||||||
|
style.textContent = `
|
||||||
|
#ooEditorModal{
|
||||||
|
--oo-header-h: 40px;
|
||||||
|
--oo-header-pad-v: 12px;
|
||||||
|
--oo-header-pad-h: 18px;
|
||||||
|
--oo-logo-h: 26px; /* tweak logo size */
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal{
|
||||||
|
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||||
|
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||||
|
background:var(--oo-modal-bg,#111)!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||||
|
#ooEditorModal .editor-header{
|
||||||
|
position:relative; display:flex; align-items:center; gap:12px;
|
||||||
|
min-height:var(--oo-header-h);
|
||||||
|
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||||
|
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||||
|
border-bottom:1px solid rgba(0,0,0,.15);
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-logo{
|
||||||
|
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||||
|
display:block; user-select:none; -webkit-user-drag:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-title{
|
||||||
|
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||||
|
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||||
|
flex:1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your scoped close button style */
|
||||||
|
#ooEditorModal .editor-close-btn{
|
||||||
|
position:absolute; top:5px; right:10px;
|
||||||
|
display:flex; justify-content:center; align-items:center;
|
||||||
|
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||||
|
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||||
|
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||||
|
transition:all .3s ease-in-out;
|
||||||
|
}
|
||||||
|
#ooEditorModal .editor-close-btn:hover{
|
||||||
|
color:#fff; background-color:#ff4d4d;
|
||||||
|
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||||
|
}
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||||
|
|
||||||
|
#ooEditorModal .editor-body{
|
||||||
|
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||||
|
}
|
||||||
|
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||||
|
|
||||||
|
#ooEditorModal .oo-warm-overlay{
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-aware background so there’s no white/gray edge
|
||||||
|
function applyModalBg(modal){
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||||
|
|| (isDark ? '#121212' : '#ffffff');
|
||||||
|
modal.style.setProperty('--oo-modal-bg', bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPageScroll(on){
|
||||||
|
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOoFullscreenModal(){
|
||||||
|
ensureOoModalCss();
|
||||||
|
let modal = document.getElementById('ooEditorModal');
|
||||||
|
if (!modal){
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'ooEditorModal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="editor-header">
|
||||||
|
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||||
|
<h3 class="editor-title"></h3>
|
||||||
|
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body">
|
||||||
|
<div id="oo-editor"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
} else {
|
||||||
|
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||||
|
// ensure logo exists and is placed before title when reusing
|
||||||
|
const header = modal.querySelector('.editor-header');
|
||||||
|
if (!header.querySelector('.editor-logo')){
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'editor-logo';
|
||||||
|
img.src = '/assets/logo.svg';
|
||||||
|
img.alt = 'FileRise logo';
|
||||||
|
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||||
|
} else {
|
||||||
|
// make sure order is logo -> title
|
||||||
|
const logo = header.querySelector('.editor-logo');
|
||||||
|
const title = header.querySelector('.editor-title');
|
||||||
|
if (logo.nextElementSibling !== title){
|
||||||
|
header.insertBefore(logo, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.focus();
|
||||||
|
lockPageScroll(true);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay lives INSIDE the modal body
|
||||||
|
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||||
|
if (!modal) return;
|
||||||
|
const body = modal.querySelector('.editor-body');
|
||||||
|
let ov = body.querySelector('.oo-warm-overlay');
|
||||||
|
if (on){
|
||||||
|
if (!ov){
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.className = 'oo-warm-overlay';
|
||||||
|
ov.textContent = label;
|
||||||
|
body.appendChild(ov);
|
||||||
|
}
|
||||||
|
} else if (ov){
|
||||||
|
ov.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||||
|
async function warmDocServerOnce(cfg){
|
||||||
|
let host = null, warmEditor = null;
|
||||||
|
try{
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||||
|
Object.assign(host.style, {
|
||||||
|
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||||
|
});
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||||
|
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||||
|
|
||||||
|
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||||
|
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||||
|
}catch{} finally{
|
||||||
|
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||||
|
try{ host?.remove(); }catch{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||||
|
async function openOnlyOffice(fileName, folder){
|
||||||
|
let editor = null;
|
||||||
|
let removeThemeListener = () => {};
|
||||||
|
let cfg = null;
|
||||||
|
let userClosed = false;
|
||||||
|
|
||||||
|
// Build our full-screen modal
|
||||||
|
const modal = ensureOoFullscreenModal();
|
||||||
|
const titleEl = modal.querySelector('.editor-title');
|
||||||
|
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||||
|
|
||||||
|
const destroy = (removeModal = true) => {
|
||||||
|
try { editor?.destroyEditor?.(); } catch {}
|
||||||
|
try { removeThemeListener(); } catch {}
|
||||||
|
if (removeModal) { try { modal.remove(); } catch {} }
|
||||||
|
lockPageScroll(false);
|
||||||
|
};
|
||||||
|
const onClose = () => { userClosed = true; destroy(true); };
|
||||||
|
|
||||||
|
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||||
|
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||||
|
|
||||||
|
try{
|
||||||
|
// 1) Fetch config
|
||||||
|
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||||
|
const resp = await fetch(url, { credentials: 'include' });
|
||||||
|
const text = await resp.text();
|
||||||
|
|
||||||
|
try { cfg = JSON.parse(text); } catch {
|
||||||
|
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||||
|
|
||||||
|
// 2) Preconnect + load DocsAPI
|
||||||
|
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||||
|
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||||
|
|
||||||
|
// 3) Theme + base events
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||||
|
cfg.editorConfig = cfg.editorConfig || {};
|
||||||
|
cfg.editorConfig.customization = Object.assign(
|
||||||
|
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||||
|
);
|
||||||
|
cfg.events.onRequestClose = () => onClose();
|
||||||
|
|
||||||
|
// 4) Warm EVERY click
|
||||||
|
if (ALWAYS_WARM_OO && !userClosed){
|
||||||
|
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||||
|
await warmDocServerOnce(cfg);
|
||||||
|
if (userClosed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Launch visible editor in full-screen modal
|
||||||
|
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||||
|
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||||
|
|
||||||
|
// Live theme switching + keep modal bg in sync
|
||||||
|
const darkToggle = document.getElementById('darkModeToggle');
|
||||||
|
const onDarkToggle = () => {
|
||||||
|
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
if (editor && typeof editor.setTheme === 'function') {
|
||||||
|
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
};
|
||||||
|
if (darkToggle) {
|
||||||
|
darkToggle.addEventListener('click', onDarkToggle);
|
||||||
|
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
console.error('[ONLYOFFICE] failed to open:', e);
|
||||||
|
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||||
|
destroy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||||
|
|
||||||
|
// ==== Editor (CodeMirror) path =============================================
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const dot = fileName.lastIndexOf(".");
|
const dot = fileName.lastIndexOf(".");
|
||||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
|
|||||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||||
|
|
||||||
const cmOptions = {
|
const cm = window.CodeMirror.fromTextArea(
|
||||||
lineNumbers: !forcePlainText,
|
|
||||||
mode: initialMode,
|
|
||||||
theme,
|
|
||||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
|
||||||
lineWrapping: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const editor = window.CodeMirror.fromTextArea(
|
|
||||||
document.getElementById("fileEditor"),
|
document.getElementById("fileEditor"),
|
||||||
cmOptions
|
{
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
|
mode: initialMode,
|
||||||
|
theme,
|
||||||
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
|
lineWrapping: false
|
||||||
|
}
|
||||||
);
|
);
|
||||||
window.currentEditor = editor;
|
window.currentEditor = cm;
|
||||||
|
|
||||||
setTimeout(adjustEditorSize, 50);
|
setTimeout(adjustEditorSize, 50);
|
||||||
observeModalResize(modal);
|
observeModalResize(modal);
|
||||||
|
|
||||||
// Font controls (now that editor exists)
|
// Font controls (now that editor exists)
|
||||||
let currentFontSize = 14;
|
let currentFontSize = 14;
|
||||||
const wrapper = editor.getWrapperElement();
|
const wrapper = cm.getWrapperElement();
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
|
|
||||||
decBtn.addEventListener("click", function () {
|
decBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
incBtn.addEventListener("click", function () {
|
incBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
|
|||||||
// Theme switch
|
// Theme switch
|
||||||
function updateEditorTheme() {
|
function updateEditorTheme() {
|
||||||
const isDark = document.body.classList.contains("dark-mode");
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
}
|
}
|
||||||
const toggle = document.getElementById("darkModeToggle");
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
|
|||||||
if (!canceled && !forcePlainText) {
|
if (!canceled && !forcePlainText) {
|
||||||
const nn = normalizeModeName(desiredMode);
|
const nn = normalizeModeName(desiredMode);
|
||||||
if (nn && isModeRegistered(nn)) {
|
if (nn && isModeRegistered(nn)) {
|
||||||
editor.setOption("mode", desiredMode);
|
cm.setOption("mode", desiredMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => { /* stay in plain text */ });
|
||||||
// If the mode truly fails to load, we just stay in plain text
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -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',
|
||||||
menu.innerHTML = "";
|
'extract_zip': 'extract_zip',
|
||||||
menuItems.forEach(item => {
|
'tag_selected': 'tag_selected',
|
||||||
let menuItem = document.createElement("div");
|
'preview': 'preview',
|
||||||
menuItem.textContent = item.label;
|
'edit': 'edit',
|
||||||
menuItem.style.padding = "5px 15px";
|
'rename': 'rename',
|
||||||
menuItem.style.cursor = "pointer";
|
'tag_file': 'tag_file'
|
||||||
menuItem.addEventListener("mouseover", () => {
|
};
|
||||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
});
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
menuItem.addEventListener("mouseout", () => {
|
if (el) setText(el, key);
|
||||||
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) {
|
|
||||||
menuItems.push({
|
|
||||||
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({
|
|
||||||
label: t("preview"),
|
|
||||||
action: () => {
|
|
||||||
const folder = window.currentFolder || "root";
|
|
||||||
previewFile(buildPreviewUrl(folder, file.name), file.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canEditFile(file.name)) {
|
|
||||||
menuItems.push({
|
|
||||||
label: t("edit"),
|
|
||||||
action: () => { editFile(selected[0], window.currentFolder); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
menuItems.push({
|
|
||||||
label: t("rename"),
|
|
||||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
|
||||||
});
|
|
||||||
|
|
||||||
menuItems.push({
|
|
||||||
label: t("tag_file"),
|
|
||||||
action: () => { openTagModal(file); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- add near top ---
|
||||||
|
let __ctxBoundOnce = false;
|
||||||
|
|
||||||
|
function docClickClose(ev) {
|
||||||
|
const m = qMenu(); if (!m || m.hidden) return;
|
||||||
|
if (!m.contains(ev.target)) hideFileContextMenu();
|
||||||
|
}
|
||||||
|
function docKeyClose(ev) {
|
||||||
|
if (ev.key === 'Escape') hideFileContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuClickDelegate(ev) {
|
||||||
|
const btn = ev.target.closest('.mi[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// CLOSE MENU FIRST so it can’t overlay the modal
|
||||||
|
hideFileContextMenu();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
window.renderFileTable = function (folder) {
|
if (typeof orig === 'function') {
|
||||||
originalRenderFileTable(folder);
|
window.renderFileTable = function (folder) {
|
||||||
bindFileListContextMenu();
|
orig(folder);
|
||||||
};
|
bindFileListContextMenu();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// If not present yet, bind once DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -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,221 @@ 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')}">×</span>
|
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||||
|
|
||||||
<!-- centered media -->
|
|
||||||
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||||
|
|
||||||
<!-- high-contrast prev/next -->
|
<!-- prev/next = rounded rectangles with centered glyphs -->
|
||||||
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
||||||
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
height:56px; min-width:48px; padding:0 14px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:38px; line-height:0;
|
||||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
||||||
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
||||||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
height:56px; min-width:48px; padding:0 14px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:38px; line-height:0;
|
||||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Absolute close “×” (like original), themed + hover behavior -->
|
||||||
|
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
|
||||||
|
position:absolute; top:8px; right:10px; z-index:1002;
|
||||||
|
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
|
||||||
|
">×</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
// Ensure a container for tags next to the title (created once)
|
||||||
|
(function ensureTitleTagsContainer() {
|
||||||
|
const titleRow = overlay.querySelector('.media-title');
|
||||||
|
if (!titleRow) return;
|
||||||
|
|
||||||
|
let tagsEl = overlay.querySelector('.title-tags');
|
||||||
|
if (!tagsEl) {
|
||||||
|
tagsEl = document.createElement('div');
|
||||||
|
tagsEl.className = 'title-tags';
|
||||||
|
Object.assign(tagsEl.style, {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '4px',
|
||||||
|
marginLeft: '6px',
|
||||||
|
maxHeight: '32px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
titleRow.appendChild(tagsEl);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// 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');
|
||||||
|
const tagsEl = overlay.querySelector('.title-tags');
|
||||||
|
|
||||||
|
// File name + tooltip
|
||||||
|
if (textEl) {
|
||||||
|
textEl.textContent = name || '';
|
||||||
|
textEl.setAttribute('title', name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// File type icon
|
||||||
|
if (iconEl) {
|
||||||
|
iconEl.textContent = getIconForFile(name);
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||||
|
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag badges next to the title
|
||||||
|
if (tagsEl) {
|
||||||
|
tagsEl.innerHTML = '';
|
||||||
|
|
||||||
|
let fileObj = null;
|
||||||
|
if (Array.isArray(fileData)) {
|
||||||
|
fileObj = fileData.find(f => f.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileObj && Array.isArray(fileObj.tags) && fileObj.tags.length) {
|
||||||
|
fileObj.tags.forEach(tag => {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.textContent = tag.name;
|
||||||
|
badge.style.backgroundColor = tag.color || '#444';
|
||||||
|
badge.style.color = '#fff';
|
||||||
|
badge.style.padding = '2px 6px';
|
||||||
|
badge.style.borderRadius = '999px';
|
||||||
|
badge.style.fontSize = '0.75rem';
|
||||||
|
badge.style.lineHeight = '1.2';
|
||||||
|
badge.style.whiteSpace = 'nowrap';
|
||||||
|
tagsEl.appendChild(badge);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +400,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 +440,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);
|
||||||
@@ -396,106 +517,120 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- 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.style.maxWidth = "88vw";
|
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||||
video.style.maxHeight = "88vh";
|
video.style.maxWidth = "88vw";
|
||||||
video.style.objectFit = "contain";
|
video.style.maxHeight = "88vh";
|
||||||
container.appendChild(video);
|
video.style.objectFit = "contain";
|
||||||
|
container.appendChild(video);
|
||||||
const markBtn = document.createElement('button');
|
|
||||||
const clearBtn = document.createElement('button');
|
// Top-right action icons (Material icons, theme-aware)
|
||||||
markBtn.className = 'btn btn-sm btn-success';
|
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||||
clearBtn.className = 'btn btn-sm btn-secondary';
|
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||||
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
|
actionWrap.appendChild(markBtnIcon);
|
||||||
clearBtn.textContent = t("clear_progress") || "Clear progress";
|
actionWrap.appendChild(clearBtnIcon);
|
||||||
actionWrap.appendChild(markBtn);
|
|
||||||
actionWrap.appendChild(clearBtn);
|
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||||
|
overlay.mediaType = 'video';
|
||||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
overlay.mediaList = videos;
|
||||||
overlay.mediaType = 'video';
|
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||||
overlay.mediaList = videos;
|
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
|
||||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
// Track which file is currently active
|
||||||
|
let currentName = name;
|
||||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
|
||||||
|
const setVideoSrc = (nm) => {
|
||||||
const SAVE_INTERVAL_MS = 5000;
|
currentName = nm;
|
||||||
let lastSaveAt = 0;
|
video.src = buildPreviewUrl(folder, nm);
|
||||||
let pending = false;
|
setTitle(overlay, nm);
|
||||||
|
};
|
||||||
async function getProgress(nm) {
|
|
||||||
try {
|
const SAVE_INTERVAL_MS = 5000;
|
||||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
let lastSaveAt = 0;
|
||||||
const data = await res.json();
|
let pending = false;
|
||||||
return data && data.state ? data.state : null;
|
|
||||||
} catch { return null; }
|
async function getProgress(nm) {
|
||||||
}
|
try {
|
||||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||||
try {
|
const data = await res.json();
|
||||||
pending = true;
|
return data && data.state ? data.state : null;
|
||||||
const res = await fetch("/api/media/updateProgress.php", {
|
} catch { return null; }
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
|
||||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
pending = false;
|
|
||||||
return data;
|
|
||||||
} catch (e) { pending = false; console.error(e); return null; }
|
|
||||||
}
|
|
||||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
|
||||||
|
|
||||||
function renderStatus(state) {
|
|
||||||
if (!statusChip) return;
|
|
||||||
// Completed
|
|
||||||
if (state && state.completed) {
|
|
||||||
|
|
||||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
|
||||||
statusChip.style.display = 'inline-block';
|
|
||||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
|
||||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
|
||||||
statusChip.style.color = '#22c55e';
|
|
||||||
markBtn.style.display = 'none';
|
|
||||||
clearBtn.style.display = '';
|
|
||||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// In progress
|
|
||||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
try {
|
||||||
statusChip.textContent = `${pct}%`;
|
pending = true;
|
||||||
statusChip.style.display = 'inline-block';
|
const res = await fetch("/api/media/updateProgress.php", {
|
||||||
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
|
method: "POST",
|
||||||
statusChip.style.background = 'rgba(250,204,21,.15)';
|
credentials: "include",
|
||||||
statusChip.style.color = '#facc15';
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
markBtn.style.display = '';
|
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||||
clearBtn.style.display = '';
|
});
|
||||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
const data = await res.json();
|
||||||
return;
|
pending = false;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
pending = false;
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// No progress
|
|
||||||
statusChip.style.display = 'none';
|
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||||
markBtn.style.display = '';
|
|
||||||
clearBtn.style.display = 'none';
|
function renderStatus(state) {
|
||||||
}
|
if (!statusChip) return;
|
||||||
|
|
||||||
function bindVideoEvents(nm) {
|
// Completed
|
||||||
const nv = video.cloneNode(true);
|
if (state && state.completed) {
|
||||||
video.replaceWith(nv);
|
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||||
video = nv;
|
statusChip.style.display = 'inline-block';
|
||||||
|
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||||
|
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||||
|
statusChip.style.color = '#22c55e';
|
||||||
|
markBtnIcon.style.display = 'none';
|
||||||
|
clearBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In progress
|
||||||
|
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)));
|
||||||
|
statusChip.textContent = `${pct}%`;
|
||||||
|
statusChip.style.display = 'inline-block';
|
||||||
|
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
const ORANGE_HEX = '#ea580c';
|
||||||
|
statusChip.style.color = ORANGE_HEX;
|
||||||
|
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No progress
|
||||||
|
statusChip.style.display = 'none';
|
||||||
|
markBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||||
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)) {
|
||||||
video.currentTime = state.seconds;
|
video.currentTime = state.seconds;
|
||||||
const seconds = Math.floor(video.currentTime || 0);
|
const seconds = Math.floor(video.currentTime || 0);
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
setFileProgressBadge(nm, seconds, duration);
|
setFileProgressBadge(nm, seconds, duration);
|
||||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||||
} else {
|
} else {
|
||||||
const ls = localStorage.getItem(lsKey(nm));
|
const ls = localStorage.getItem(lsKey(nm));
|
||||||
@@ -506,20 +641,24 @@ setFileProgressBadge(nm, seconds, duration);
|
|||||||
renderStatus(null);
|
renderStatus(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
video.addEventListener("timeupdate", async () => {
|
video.addEventListener("timeupdate", async () => {
|
||||||
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 seconds = Math.floor(video.currentTime || 0);
|
|
||||||
|
const nm = currentName;
|
||||||
|
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 {}
|
||||||
renderStatus({ seconds, duration, completed: false });
|
renderStatus({ seconds, duration, completed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {}
|
||||||
@@ -527,50 +666,54 @@ setFileProgressBadge(nm, seconds, duration);
|
|||||||
setFileWatchedBadge(nm, true);
|
setFileWatchedBadge(nm, true);
|
||||||
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);
|
renderStatus(null);
|
||||||
bindVideoEvents(nm);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videos.length > 1) {
|
|
||||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
|
||||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
|
||||||
if (e.key === "ArrowLeft") navigate(-1);
|
|
||||||
if (e.key === "ArrowRight") navigate(+1);
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
overlay._onKey = onKey;
|
if (videos.length > 1) {
|
||||||
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
|
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (!document.body.contains(overlay)) {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft") navigate(-1);
|
||||||
|
if (e.key === "ArrowRight") navigate(+1);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
overlay._onKey = onKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoSrc(name);
|
||||||
|
renderStatus(null);
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVideoSrc(name);
|
|
||||||
renderStatus(null);
|
|
||||||
bindVideoEvents(name);
|
|
||||||
overlay.style.display = "flex";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- AUDIO / OTHER -------------------- */
|
/* -------------------- AUDIO / OTHER -------------------- */
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
const audio = document.createElement("audio");
|
const audio = document.createElement("audio");
|
||||||
|
|||||||
@@ -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 = `
|
|
||||||
<div class="modal-content" style="width: 450px; max-width:90vw;">
|
|
||||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
|
||||||
<h3 style="
|
|
||||||
margin:0;
|
|
||||||
display:inline-block;
|
|
||||||
max-width: calc(100% - 40px);
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
">
|
|
||||||
${t("tag_file")}: ${escapeHTML(file.name)}
|
|
||||||
</h3>
|
|
||||||
<span id="closeTagModal" class="editor-close-btn">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="margin-top:10px;">
|
|
||||||
<label for="tagNameInput">${t("tag_name")}</label>
|
|
||||||
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
|
||||||
<br><br>
|
|
||||||
<label for="tagColorInput">${t("tag_name")}</label>
|
|
||||||
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
|
||||||
<br><br>
|
|
||||||
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
|
||||||
<!-- Custom tag options will be populated here -->
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div style="text-align:right;">
|
|
||||||
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
|
|
||||||
</div>
|
|
||||||
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
|
|
||||||
<!-- Existing tags will be listed here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
updateCustomTagDropdown();
|
// Global store (preserve existing behavior)
|
||||||
|
window.globalTags = window.globalTags || [];
|
||||||
document.getElementById('closeTagModal').addEventListener('click', () => {
|
if (localStorage.getItem('globalTags')) {
|
||||||
modal.remove();
|
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
updateTagModalDisplay(file);
|
|
||||||
updateFileRowTagDisplay(file);
|
|
||||||
saveFileTags(file);
|
|
||||||
if (window.viewMode === 'gallery') {
|
|
||||||
renderGalleryView(window.currentFolder);
|
|
||||||
} else {
|
|
||||||
renderFileTable(window.currentFolder);
|
|
||||||
}
|
|
||||||
document.getElementById('tagNameInput').value = '';
|
|
||||||
updateCustomTagDropdown();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------- ensure DOM (create-once-if-missing) --------------------
|
||||||
* Open a modal to tag multiple files.
|
function ensureSingleTagModal() {
|
||||||
* @param {Array} files - Array of file objects to tag.
|
// de-dupe if something already injected multiples
|
||||||
*/
|
const all = document.querySelectorAll('#tagModal');
|
||||||
export function openMultiTagModal(files) {
|
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
|
||||||
let modal = document.createElement('div');
|
|
||||||
modal.id = 'multiTagModal';
|
let modal = document.getElementById('tagModal');
|
||||||
modal.className = 'modal';
|
if (!modal) {
|
||||||
modal.innerHTML = `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="modal-content" style="width: 450px; max-width:90vw;">
|
<div id="tagModal" class="modal" style="display:none">
|
||||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="modal-content" style="width:450px; max-width:90vw;">
|
||||||
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
<h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
</div>
|
${t('tag_file')}
|
||||||
<div class="modal-body" style="margin-top:10px;">
|
</h3>
|
||||||
<label for="multiTagNameInput">Tag Name:</label>
|
<span id="closeTagModal" class="editor-close-btn">×</span>
|
||||||
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
</div>
|
||||||
<br><br>
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
<label for="multiTagColorInput">Tag Color:</label>
|
<label for="tagNameInput">${t('tag_name')}</label>
|
||||||
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
<input type="text" id="tagNameInput" placeholder="${t('tag_name')}" 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;">
|
<label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
|
||||||
<!-- Custom tag options will be populated here -->
|
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
</div>
|
<br><br>
|
||||||
<br>
|
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
|
||||||
<div style="text-align:right;">
|
<br>
|
||||||
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
|
<div style="text-align:right;">
|
||||||
|
<button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
|
||||||
|
</div>
|
||||||
|
<div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`);
|
||||||
`;
|
modal = document.getElementById('tagModal');
|
||||||
document.body.appendChild(modal);
|
}
|
||||||
modal.style.display = 'block';
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
updateMultiCustomTagDropdown();
|
function ensureMultiTagModal() {
|
||||||
|
const all = document.querySelectorAll('#multiTagModal');
|
||||||
|
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
|
||||||
|
|
||||||
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
|
let modal = document.getElementById('multiTagModal');
|
||||||
modal.remove();
|
if (!modal) {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="multiTagModal" class="modal" style="display:none">
|
||||||
|
<div class="modal-content" style="width:450px; max-width:90vw;">
|
||||||
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h3 id="multiTagTitle" style="margin:0;"></h3>
|
||||||
|
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
|
<label for="multiTagNameInput">${t('tag_name')}</label>
|
||||||
|
<input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
|
||||||
|
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
|
||||||
|
<br>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
modal = document.getElementById('multiTagModal');
|
||||||
|
}
|
||||||
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
|
// Input filter for dropdown
|
||||||
updateMultiCustomTagDropdown(e.target.value);
|
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('');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
|
__singleInit = true;
|
||||||
const tagName = document.getElementById('multiTagNameInput').value.trim();
|
}
|
||||||
const tagColor = document.getElementById('multiTagColorInput').value;
|
|
||||||
if (!tagName) {
|
function initMultiModalOnce() {
|
||||||
alert('Please enter a tag name.');
|
if (__multiInit) return;
|
||||||
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,16 +263,16 @@ 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the modal display to show current tags on the file.
|
// Update the modal display to show current tags on the file.
|
||||||
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(response => response.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success && data.globalTags) {
|
||||||
console.log("Global tag removed:", tagName);
|
window.globalTags = data.globalTags;
|
||||||
if (data.globalTags) {
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
window.globalTags = data.globalTags;
|
updateCustomTagDropdown();
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
updateMultiCustomTagDropdown();
|
||||||
updateCustomTagDropdown();
|
} else if (!data.success) {
|
||||||
updateMultiCustomTagDropdown();
|
console.error("Error removing global tag:", data.error);
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
console.error("Error removing global tag:", data.error);
|
.catch(err => console.error("Error removing global tag:", err));
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(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,142 +353,113 @@ 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');
|
||||||
badgeContainer.className = 'tag-badges';
|
badgeContainer.className = 'tag-badges';
|
||||||
badgeContainer.style.display = 'inline-block';
|
badgeContainer.style.display = 'inline-block';
|
||||||
badgeContainer.style.marginLeft = '5px';
|
badgeContainer.style.marginLeft = '5px';
|
||||||
cell.appendChild(badgeContainer);
|
cell.appendChild(badgeContainer);
|
||||||
}
|
|
||||||
badgeContainer.innerHTML = '';
|
|
||||||
if (file.tags && file.tags.length > 0) {
|
|
||||||
file.tags.forEach(tag => {
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.textContent = tag.name;
|
|
||||||
badge.style.backgroundColor = tag.color;
|
|
||||||
badge.style.color = '#fff';
|
|
||||||
badge.style.padding = '2px 4px';
|
|
||||||
badge.style.marginRight = '2px';
|
|
||||||
badge.style.borderRadius = '3px';
|
|
||||||
badge.style.fontSize = '0.8em';
|
|
||||||
badge.style.verticalAlign = 'middle';
|
|
||||||
badgeContainer.appendChild(badge);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
badgeContainer.innerHTML = '';
|
||||||
|
(file.tags || []).forEach(tag => {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.textContent = tag.name;
|
||||||
|
badge.style.backgroundColor = tag.color;
|
||||||
|
badge.style.color = '#fff';
|
||||||
|
badge.style.padding = '2px 4px';
|
||||||
|
badge.style.marginRight = '2px';
|
||||||
|
badge.style.borderRadius = '3px';
|
||||||
|
badge.style.fontSize = '0.8em';
|
||||||
|
badge.style.verticalAlign = 'middle';
|
||||||
|
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) {
|
|
||||||
if (window.currentTagFilter && window.currentTagFilter !== '') {
|
|
||||||
return files.filter(file => {
|
|
||||||
if (file.tags && file.tags.length > 0) {
|
|
||||||
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return files;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterFilesByTag(files) {
|
||||||
|
const q = (window.currentTagFilter || '').trim().toLowerCase();
|
||||||
|
if (!q) return files;
|
||||||
|
return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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,33 @@ 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 can’t be opened.",
|
||||||
|
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
|
||||||
|
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
|
||||||
|
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||||
|
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||||
|
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||||
|
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||||
|
"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",
|
||||||
|
"hide_header_zoom_controls": "Hide header zoom controls"
|
||||||
},
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -62,23 +62,43 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginTip(message) {
|
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) {
|
||||||
@@ -403,39 +449,91 @@ function bindDarkMode() {
|
|||||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||||
try {
|
try {
|
||||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||||
|
|
||||||
// Always keep <title> correct early (no visual flicker)
|
// Always keep <title> correct early (no visual flicker)
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
// --- 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;
|
// be tolerant to key variants just in case
|
||||||
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
const row = $('#loginForm');
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
if (row) {
|
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||||
if (disableForm) {
|
|
||||||
row.setAttribute('hidden', '');
|
const showForm = !disableForm;
|
||||||
row.style.display = ''; // don't leave display:none lying around
|
const showOIDC = !disableOIDC;
|
||||||
|
const showBasic = !disableBasic;
|
||||||
|
|
||||||
|
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||||
|
const authForm = $('#authForm'); // inner username/password form
|
||||||
|
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||||
|
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
|
|
||||||
|
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||||
|
if (loginWrap) {
|
||||||
|
const anyMethod = showForm || showOIDC || showBasic;
|
||||||
|
if (anyMethod) {
|
||||||
|
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||||
|
loginWrap.style.display = ''; // let CSS decide
|
||||||
} else {
|
} else {
|
||||||
row.removeAttribute('hidden');
|
loginWrap.setAttribute('hidden', '');
|
||||||
row.style.display = '';
|
loginWrap.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
|
||||||
|
// 2) Toggle the pieces inside the wrapper
|
||||||
|
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||||
|
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||||
|
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||||
|
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||||
|
|
||||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||||
if (phase === 'final') {
|
if (phase === 'final') {
|
||||||
const h1 = document.querySelector('.header-title h1');
|
const h1 = document.querySelector('.header-title h1');
|
||||||
if (h1) {
|
if (h1) {
|
||||||
// prevent i18n or legacy from overwriting it
|
// prevent i18n or legacy from overwriting it
|
||||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||||
|
|
||||||
if (h1.textContent !== title) h1.textContent = title;
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
|
|
||||||
// lock it so late code can't stomp it
|
// lock it so late code can't stomp it
|
||||||
if (!h1.__titleLock) {
|
if (!h1.__titleLock) {
|
||||||
const mo = new MutationObserver(() => {
|
const mo = new MutationObserver(() => {
|
||||||
@@ -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';
|
||||||
@@ -1102,7 +1230,7 @@ function bindDarkMode() {
|
|||||||
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
||||||
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
|
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
382
public/js/portal-login.js
Normal 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
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
function showConfirm(message, onConfirm) {
|
function showConfirm(message, onConfirm) {
|
||||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
|||||||
toggleVisibility("restoreFilesModal", false);
|
toggleVisibility("restoreFilesModal", false);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -3,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 Drag–and–Drop Folder Uploads (Original Code)
|
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
@@ -82,23 +317,35 @@ function getFilesFromDataTransferItems(items) {
|
|||||||
|
|
||||||
function setDropAreaDefault() {
|
function setDropAreaDefault() {
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) {
|
if (!dropArea) return;
|
||||||
dropArea.innerHTML = `
|
|
||||||
<div id="uploadInstruction" class="upload-instruction">
|
dropArea.innerHTML = `
|
||||||
${t("upload_instruction")}
|
<div id="uploadInstruction" class="upload-instruction">
|
||||||
|
${t("upload_instruction")}
|
||||||
|
</div>
|
||||||
|
<div id="uploadFileRow" class="upload-file-row">
|
||||||
|
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
||||||
|
</div>
|
||||||
|
<div id="fileInfoWrapper" class="file-info-wrapper">
|
||||||
|
<div id="fileInfoContainer" class="file-info-container">
|
||||||
|
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="uploadFileRow" class="upload-file-row">
|
</div>
|
||||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
<!-- File input for file picker (files only) -->
|
||||||
</div>
|
<input
|
||||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
type="file"
|
||||||
<div id="fileInfoContainer" class="file-info-container">
|
id="file"
|
||||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
name="file[]"
|
||||||
</div>
|
class="form-control-file"
|
||||||
</div>
|
multiple
|
||||||
<!-- File input for file picker (files only) -->
|
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,18 +721,20 @@ 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;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -492,8 +742,13 @@ 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.
|
||||||
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
||||||
let list;
|
let list;
|
||||||
@@ -509,7 +764,7 @@ async function initResumableUpload() {
|
|||||||
} else {
|
} else {
|
||||||
list = listWrapper.querySelector("ul.upload-progress-list");
|
list = listWrapper.querySelector("ul.upload-progress-list");
|
||||||
}
|
}
|
||||||
|
|
||||||
const li = createFileEntry(file);
|
const li = createFileEntry(file);
|
||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
@@ -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");
|
||||||
progressContainer.innerHTML = "";
|
if (progressContainer) {
|
||||||
|
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) {
|
||||||
@@ -716,12 +1043,12 @@ function submitFiles(allFiles) {
|
|||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
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,39 +1057,40 @@ 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;
|
||||||
const failedCount = allFiles.length - succeededCount;
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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,38 +1136,42 @@ 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++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
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();
|
|
||||||
|
|
||||||
// Drag–and–drop events (for folder uploads) use original processing.
|
// Drag–and–drop 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 () {
|
|
||||||
if (fileInput) fileInput.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput) {
|
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
|
||||||
fileInput.addEventListener("change", async function () {
|
dropArea.addEventListener("click", function (e) {
|
||||||
const files = Array.from(fileInput.files || []);
|
// If the click originated from the "Choose files" button or the file input itself,
|
||||||
if (!files.length) return;
|
// let their handlers deal with it.
|
||||||
|
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
|
||||||
if (useResumable) {
|
return;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
triggerFilePickerOnce();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadForm) {
|
if (uploadForm && !uploadForm.__uploadSubmitBound) {
|
||||||
|
uploadForm.__uploadSubmitBound = true;
|
||||||
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 };
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v1.8.7';
|
window.APP_VERSION = 'v2.1.0';
|
||||||
|
|||||||
92
public/js/zoom.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// /js/zoom.js
|
||||||
|
(function () {
|
||||||
|
const MIN_PERCENT = 60; // 60%
|
||||||
|
const MAX_PERCENT = 140; // 140%
|
||||||
|
const STEP_PERCENT = 5; // 5%
|
||||||
|
const STORAGE_KEY = 'filerise.appZoomPercent';
|
||||||
|
|
||||||
|
function clampPercent(p) {
|
||||||
|
return Math.max(MIN_PERCENT, Math.min(MAX_PERCENT, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplay(p) {
|
||||||
|
const el = document.getElementById('zoomDisplay');
|
||||||
|
if (el) el.textContent = `${p}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoomPercent(p) {
|
||||||
|
const clamped = clampPercent(p);
|
||||||
|
const scale = clamped / 100;
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--app-zoom', String(scale));
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, String(clamped)); } catch {}
|
||||||
|
|
||||||
|
updateDisplay(clamped);
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPercent() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) return clampPercent(n);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const v = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--app-zoom')
|
||||||
|
.trim();
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
return clampPercent(Math.round(n * 100));
|
||||||
|
}
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public-ish API (percent-based)
|
||||||
|
window.fileriseZoom = {
|
||||||
|
in() {
|
||||||
|
const next = getCurrentPercent() + STEP_PERCENT;
|
||||||
|
return applyZoomPercent(next);
|
||||||
|
},
|
||||||
|
out() {
|
||||||
|
const next = getCurrentPercent() - STEP_PERCENT;
|
||||||
|
return applyZoomPercent(next);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
return applyZoomPercent(100);
|
||||||
|
},
|
||||||
|
setPercent(p) {
|
||||||
|
return applyZoomPercent(p);
|
||||||
|
},
|
||||||
|
currentPercent: getCurrentPercent
|
||||||
|
};
|
||||||
|
|
||||||
|
function initZoomUI() {
|
||||||
|
// bind buttons
|
||||||
|
const btns = document.querySelectorAll('.zoom-btn[data-zoom]');
|
||||||
|
btns.forEach(btn => {
|
||||||
|
if (btn.__zoomBound) return;
|
||||||
|
btn.__zoomBound = true;
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const mode = btn.dataset.zoom;
|
||||||
|
if (mode === 'in') window.fileriseZoom.in();
|
||||||
|
else if (mode === 'out') window.fileriseZoom.out();
|
||||||
|
else if (mode === 'reset') window.fileriseZoom.reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// apply initial zoom + update display
|
||||||
|
const initial = getCurrentPercent();
|
||||||
|
applyZoomPercent(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run immediately if DOM is ready, otherwise wait
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initZoomUI, { once: true });
|
||||||
|
} else {
|
||||||
|
initZoomUI();
|
||||||
|
}
|
||||||
|
})();
|
||||||
146
public/portal-login.html
Normal 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;">
|
||||||
|
You’ll 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
@@ -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>
|
||||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 698 KiB |
BIN
resources/dark-user-groups.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
BIN
resources/filerise-v2.0.0.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 666 KiB |
BIN
resources/portal-login.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
resources/portal-optional-form.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
72
scripts/manual-sync.sh
Normal 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 (don’t fail the whole script if reload isn’t 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
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../../config/config.php';
|
||||||
|
require __DIR__ . '/../../src/models/FileModel.php';
|
||||||
|
|
||||||
|
$token = $argv[1] ?? '';
|
||||||
|
$token = preg_replace('/[^a-f0-9]/','',$token);
|
||||||
|
if ($token === '') { fwrite(STDERR, "No token\n"); exit(1); }
|
||||||
|
|
||||||
|
$root = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||||
|
$tokDir = $root . '/.tokens';
|
||||||
|
$logDir = $root . '/.logs';
|
||||||
|
@mkdir($tokDir, 0775, true);
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
|
||||||
|
$tokFile = $tokDir . '/' . $token . '.json';
|
||||||
|
$logFile = $logDir . '/WORKER-' . $token . '.log';
|
||||||
|
|
||||||
|
file_put_contents($logFile, "[".date('c')."] worker start token={$token}\n", FILE_APPEND);
|
||||||
|
|
||||||
|
// Keep libzip temp files on same FS as final zip (prevents cross-device rename failures)
|
||||||
|
@mkdir($root, 0775, true);
|
||||||
|
@putenv('TMPDIR='.$root);
|
||||||
|
@ini_set('sys_temp_dir', $root);
|
||||||
|
|
||||||
|
// Small janitor: purge old tokens/logs (> 6h)
|
||||||
|
$now = time();
|
||||||
|
foreach (glob($tokDir.'/*.json') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||||
|
foreach (glob($logDir.'/WORKER-*.log') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||||
|
|
||||||
|
// Helpers to read/write the token file safely
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
|
||||||
|
$save = function() use (&$job, $tokFile) {
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
@clearstatcache(true, $tokFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
$touchPhase = function(string $phase) use (&$job, $save) {
|
||||||
|
$job['phase'] = $phase;
|
||||||
|
$save();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init timing
|
||||||
|
if (empty($job['startedAt'])) {
|
||||||
|
$job['startedAt'] = time();
|
||||||
|
}
|
||||||
|
$job['status'] = 'working';
|
||||||
|
$job['error'] = null;
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// Build the list of files to zip using the model (same validation FileRise uses)
|
||||||
|
try {
|
||||||
|
// Reuse FileModel’s validation by calling it but not keeping the zip; we’ll enumerate sizes here.
|
||||||
|
$folder = (string)($job['folder'] ?? 'root');
|
||||||
|
$names = (array)($job['files'] ?? []);
|
||||||
|
|
||||||
|
// Resolve folder path similarly to createZipArchive
|
||||||
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
|
if ($baseDir === false) {
|
||||||
|
throw new RuntimeException('Uploads directory not configured correctly.');
|
||||||
|
}
|
||||||
|
if (strtolower($folder) === 'root' || $folder === "") {
|
||||||
|
$folderPathReal = $baseDir;
|
||||||
|
} else {
|
||||||
|
if (strpos($folder, '..') !== false) throw new RuntimeException('Invalid folder name.');
|
||||||
|
$parts = explode('/', trim($folder, "/\\ "));
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||||
|
throw new RuntimeException('Invalid folder name.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
|
$folderPathReal = realpath($folderPath);
|
||||||
|
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||||
|
throw new RuntimeException('Folder not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect files (only regular files)
|
||||||
|
$filesToZip = [];
|
||||||
|
foreach ($names as $nm) {
|
||||||
|
$bn = basename(trim((string)$nm));
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $bn)) continue;
|
||||||
|
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
|
||||||
|
if (is_file($fp)) $filesToZip[] = $fp;
|
||||||
|
}
|
||||||
|
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
|
||||||
|
|
||||||
|
// Totals for progress
|
||||||
|
$filesTotal = count($filesToZip);
|
||||||
|
$bytesTotal = 0;
|
||||||
|
foreach ($filesToZip as $fp) {
|
||||||
|
$sz = @filesize($fp);
|
||||||
|
if ($sz !== false) $bytesTotal += (int)$sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job['filesTotal'] = $filesTotal;
|
||||||
|
$job['bytesTotal'] = $bytesTotal;
|
||||||
|
$job['filesDone'] = 0;
|
||||||
|
$job['bytesDone'] = 0;
|
||||||
|
$job['pct'] = 0;
|
||||||
|
$job['current'] = null;
|
||||||
|
$job['phase'] = 'zipping';
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// Create final zip path in META_DIR/ziptmp
|
||||||
|
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
||||||
|
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new RuntimeException('Could not create zip archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate files; report up to 98%
|
||||||
|
$bytesDone = 0;
|
||||||
|
$filesDone = 0;
|
||||||
|
foreach ($filesToZip as $fp) {
|
||||||
|
$bn = basename($fp);
|
||||||
|
$zip->addFile($fp, $bn);
|
||||||
|
|
||||||
|
$filesDone++;
|
||||||
|
$sz = @filesize($fp);
|
||||||
|
if ($sz !== false) $bytesDone += (int)$sz;
|
||||||
|
|
||||||
|
$job['filesDone'] = $filesDone;
|
||||||
|
$job['bytesDone'] = $bytesDone;
|
||||||
|
$job['current'] = $bn;
|
||||||
|
|
||||||
|
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
|
||||||
|
if ($pct < 0) $pct = 0;
|
||||||
|
if ($pct > 98) $pct = 98;
|
||||||
|
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
|
||||||
|
|
||||||
|
$save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalizing (this is where libzip writes & renames)
|
||||||
|
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
|
||||||
|
$job['phase'] = 'finalizing';
|
||||||
|
$job['finalizeAt'] = time();
|
||||||
|
|
||||||
|
// Publish selected totals for a truthful UI during finalizing,
|
||||||
|
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
|
||||||
|
$job['selectedFiles'] = $filesTotal;
|
||||||
|
$job['selectedBytes'] = $bytesTotal;
|
||||||
|
$job['filesDone'] = null;
|
||||||
|
$job['bytesDone'] = null;
|
||||||
|
$job['current'] = null;
|
||||||
|
|
||||||
|
$save();
|
||||||
|
|
||||||
|
// ---- finalize the zip on disk ----
|
||||||
|
$ok = $zip->close();
|
||||||
|
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
|
||||||
|
|
||||||
|
if (!$ok || !is_file($zipPath)) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job['status'] = 'done';
|
||||||
|
$job['zipPath'] = $zipPath;
|
||||||
|
$job['pct'] = 100;
|
||||||
|
$job['phase'] = 'finalized';
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Worker exception: '.$e->getMessage();
|
||||||
|
$save();
|
||||||
|
file_put_contents($logFile, "[".date('c')."] exception: ".$e->getMessage()."\n", FILE_APPEND);
|
||||||
|
}
|
||||||
166
src/controllers/AclAdminController.php
Normal 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}]}');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,91 +7,702 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
|||||||
|
|
||||||
class AdminController
|
class AdminController
|
||||||
{
|
{
|
||||||
public function getConfig(): void
|
|
||||||
{
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
|
|
||||||
$config = AdminModel::getConfig();
|
/** Enforce authentication (401). */
|
||||||
if (isset($config['error'])) {
|
public static function requireAuth(): void
|
||||||
http_response_code(500);
|
{
|
||||||
header('Cache-Control: no-store');
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
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 user’s 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
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$config = AdminModel::getConfig();
|
||||||
|
if (isset($config['error'])) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
|
||||||
|
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
|
||||||
|
$effEnabled = defined('ONLYOFFICE_ENABLED')
|
||||||
|
? (bool) ONLYOFFICE_ENABLED
|
||||||
|
: (bool) ($ooCfg['enabled'] ?? false);
|
||||||
|
|
||||||
|
$effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
|
||||||
|
? (string) ONLYOFFICE_DOCS_ORIGIN
|
||||||
|
: (string) ($ooCfg['docsOrigin'] ?? '');
|
||||||
|
|
||||||
|
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
|
||||||
|
? (ONLYOFFICE_JWT_SECRET !== '')
|
||||||
|
: (!empty($ooCfg['jwtSecret']));
|
||||||
|
|
||||||
|
$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)
|
||||||
|
$public = [
|
||||||
|
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
||||||
|
'loginOptions' => [
|
||||||
|
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||||
|
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||||
|
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||||
|
],
|
||||||
|
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
|
||||||
|
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||||
|
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||||
|
'oidc' => [
|
||||||
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
|
// never include clientId/clientSecret
|
||||||
|
],
|
||||||
|
'onlyoffice' => [
|
||||||
|
// Public only needs to know if it’s on; no secrets/origins here.
|
||||||
|
'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']);
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
|
||||||
|
$adminExtra = [
|
||||||
|
'loginOptions' => array_merge($public['loginOptions'], [
|
||||||
|
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||||
|
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||||
|
]),
|
||||||
|
'oidc' => array_merge($public['oidc'], [
|
||||||
|
'hasClientId' => !empty($config['oidc']['clientId']),
|
||||||
|
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||||
|
]),
|
||||||
|
'onlyoffice' => [
|
||||||
|
'enabled' => $effEnabled,
|
||||||
|
'docsOrigin' => $effDocs, // effective (constants win)
|
||||||
|
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
|
||||||
|
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
|
||||||
|
'lockedByPhp' => (
|
||||||
|
defined('ONLYOFFICE_ENABLED')
|
||||||
|
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|
||||||
|
|| defined('ONLYOFFICE_JWT_SECRET')
|
||||||
|
|| defined('ONLYOFFICE_PUBLIC_ORIGIN')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
header('Cache-Control: no-store'); // don’t cache admin config
|
||||||
|
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admins / unauthenticated: only the public subset
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
|
$license = isset($data['license']) ? trim((string)$data['license']) : '';
|
||||||
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
|
|
||||||
$effEnabled = defined('ONLYOFFICE_ENABLED')
|
|
||||||
? (bool) ONLYOFFICE_ENABLED
|
|
||||||
: (bool) ($ooCfg['enabled'] ?? false);
|
|
||||||
|
|
||||||
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== ''
|
// Store license + updatedAt in JSON file
|
||||||
? (string) ONLYOFFICE_DOCS_ORIGIN
|
if (!defined('PRO_LICENSE_FILE')) {
|
||||||
: (string) ($ooCfg['docsOrigin'] ?? '');
|
// Fallback if constant not defined for some reason
|
||||||
|
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
|
||||||
|
}
|
||||||
|
|
||||||
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
|
$payload = [
|
||||||
? (ONLYOFFICE_JWT_SECRET !== '')
|
'license' => $license,
|
||||||
: (!empty($ooCfg['jwtSecret']));
|
'updatedAt' => gmdate('c'),
|
||||||
|
|
||||||
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
|
|
||||||
|
|
||||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
|
||||||
$public = [
|
|
||||||
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
|
||||||
'loginOptions' => [
|
|
||||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
|
||||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
|
||||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
|
||||||
],
|
|
||||||
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
|
|
||||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
|
||||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
|
||||||
'oidc' => [
|
|
||||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
|
||||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
|
||||||
// never include clientId/clientSecret
|
|
||||||
],
|
|
||||||
'onlyoffice' => [
|
|
||||||
// Public only needs to know if it’s on; no secrets/origins here.
|
|
||||||
'enabled' => $effEnabled,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
$dir = dirname(PRO_LICENSE_FILE);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||||
if ($isAdmin) {
|
http_response_code(500);
|
||||||
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
|
echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
|
||||||
$adminExtra = [
|
|
||||||
'loginOptions' => array_merge($public['loginOptions'], [
|
|
||||||
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
|
||||||
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
|
||||||
]),
|
|
||||||
'oidc' => array_merge($public['oidc'], [
|
|
||||||
'hasClientId' => !empty($config['oidc']['clientId']),
|
|
||||||
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
|
||||||
]),
|
|
||||||
'onlyoffice' => [
|
|
||||||
'enabled' => $effEnabled,
|
|
||||||
'docsOrigin' => $effDocs, // effective (constants win)
|
|
||||||
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
|
|
||||||
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
|
|
||||||
'lockedByPhp' => (
|
|
||||||
defined('ONLYOFFICE_ENABLED')
|
|
||||||
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|
|
||||||
|| defined('ONLYOFFICE_JWT_SECRET')
|
|
||||||
),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
header('Cache-Control: no-store'); // don’t cache admin config
|
|
||||||
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-admins / unauthenticated: only the public subset
|
$json = json_encode($payload, JSON_PRETTY_PRINT);
|
||||||
header('Cache-Control: no-store');
|
if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
|
||||||
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
http_response_code(500);
|
||||||
return;
|
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
|
||||||
{
|
{
|
||||||
@@ -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)
|
||||||
@@ -250,6 +867,7 @@ class AdminController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// —– persist merged config —–
|
// —– persist merged config —–
|
||||||
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
|
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -57,12 +57,26 @@ class AuthController
|
|||||||
$oidcAction = 'callback';
|
$oidcAction = 'callback';
|
||||||
}
|
}
|
||||||
if ($oidcAction) {
|
if ($oidcAction) {
|
||||||
$cfg = AdminModel::getConfig();
|
$cfg = AdminModel::getConfig();
|
||||||
|
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||||||
|
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||||||
|
// When configured as a public client (no secret), pass null, not an empty string.
|
||||||
|
if ($clientSecret === '') { $clientSecret = null; }
|
||||||
|
|
||||||
$oidc = new OpenIDConnectClient(
|
$oidc = new OpenIDConnectClient(
|
||||||
$cfg['oidc']['providerUrl'],
|
$cfg['oidc']['providerUrl'],
|
||||||
$cfg['oidc']['clientId'],
|
$clientId ?: null,
|
||||||
$cfg['oidc']['clientSecret']
|
$clientSecret
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
|
||||||
|
if (method_exists($oidc, 'setCodeChallengeMethod')) {
|
||||||
|
$oidc->setCodeChallengeMethod('S256');
|
||||||
|
}
|
||||||
|
// client_secret_post with Authelia using config.php
|
||||||
|
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||||
|
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
|
||||||
|
}
|
||||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||||
$oidc->addScope(['openid','profile','email']);
|
$oidc->addScope(['openid','profile','email']);
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,59 @@ class FileController
|
|||||||
return $ok ? null : "Forbidden: folder scope violation.";
|
return $ok ? null : "Forbidden: folder scope violation.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function spawnZipWorker(string $token, string $tokFile, string $logDir): array
|
||||||
|
{
|
||||||
|
$worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php');
|
||||||
|
if (!$worker || !is_file($worker)) {
|
||||||
|
return ['ok'=>false, 'error'=>'zip_worker.php not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a PHP CLI binary that actually works
|
||||||
|
$candidates = array_values(array_filter([
|
||||||
|
PHP_BINARY ?: null,
|
||||||
|
'/usr/local/bin/php',
|
||||||
|
'/usr/bin/php',
|
||||||
|
'/bin/php'
|
||||||
|
]));
|
||||||
|
$php = null;
|
||||||
|
foreach ($candidates as $bin) {
|
||||||
|
if (!$bin) continue;
|
||||||
|
$rc = 1;
|
||||||
|
@exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc);
|
||||||
|
if ($rc === 0) { $php = $bin; break; }
|
||||||
|
}
|
||||||
|
if (!$php) {
|
||||||
|
return ['ok'=>false, 'error'=>'No working php CLI found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log';
|
||||||
|
|
||||||
|
// Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process.
|
||||||
|
$tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||||
|
@mkdir($tmpDir, 0775, true);
|
||||||
|
|
||||||
|
// Build one sh -c string so env + nohup + echo $! are in the same shell
|
||||||
|
$cmdStr =
|
||||||
|
'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' .
|
||||||
|
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) .
|
||||||
|
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||||
|
|
||||||
|
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||||
|
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||||
|
|
||||||
|
// Persist spawn metadata into token (best-effort)
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
$job['spawn'] = [
|
||||||
|
'ts' => time(),
|
||||||
|
'php' => $php,
|
||||||
|
'pid' => $pid,
|
||||||
|
'log' => $logFile
|
||||||
|
];
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
|
return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID'];
|
||||||
|
}
|
||||||
|
|
||||||
// --- small helpers ---
|
// --- small helpers ---
|
||||||
private function _jsonStart(): void {
|
private function _jsonStart(): void {
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
@@ -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,120 +811,235 @@ 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);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function downloadZip()
|
public function zipStatus()
|
||||||
{
|
{
|
||||||
try {
|
if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; }
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$this->_checkCsrf()) { http_response_code(400); echo "Bad CSRF"; return; }
|
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||||
if (!$this->_requireAuth()) { http_response_code(401); echo "Unauthorized"; return; }
|
if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; }
|
||||||
|
|
||||||
$data = $this->_readJsonBody();
|
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||||
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; }
|
||||||
http_response_code(400); echo "Invalid input."; return;
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
}
|
if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; }
|
||||||
|
|
||||||
$folder = $this->_normalizeFolder($data['folder']);
|
$ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']);
|
||||||
$files = $data['files'];
|
|
||||||
if (!$this->_validFolder($folder)) { http_response_code(400); echo "Invalid folder name."; return; }
|
$out = [
|
||||||
|
'status' => $job['status'] ?? 'unknown',
|
||||||
$username = $_SESSION['username'] ?? '';
|
'error' => $job['error'] ?? null,
|
||||||
$perms = $this->loadPerms($username);
|
'ready' => $ready,
|
||||||
|
// progress (if present)
|
||||||
// Optional zip gate by account flag
|
'pct' => $job['pct'] ?? null,
|
||||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
'filesDone' => $job['filesDone'] ?? null,
|
||||||
http_response_code(403); echo "ZIP downloads are not allowed for your account."; return;
|
'filesTotal' => $job['filesTotal'] ?? null,
|
||||||
}
|
'bytesDone' => $job['bytesDone'] ?? null,
|
||||||
|
'bytesTotal' => $job['bytesTotal'] ?? null,
|
||||||
$ignoreOwnership = $this->isAdmin($perms)
|
'current' => $job['current'] ?? null,
|
||||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
'phase' => $job['phase'] ?? null,
|
||||||
|
// timing (always include for UI)
|
||||||
// Ancestor-owner counts as full view
|
'startedAt' => $job['startedAt'] ?? null,
|
||||||
$fullView = $ignoreOwnership
|
'finalizeAt' => $job['finalizeAt'] ?? null,
|
||||||
|| ACL::canRead($username, $perms, $folder)
|
];
|
||||||
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
|
||||||
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
if ($ready) {
|
||||||
|
$out['size'] = @filesize($job['zipPath']) ?: null;
|
||||||
if (!$fullView && !$ownOnly) { http_response_code(403); echo "Forbidden: no view access to this folder."; return; }
|
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
|
||||||
|
}
|
||||||
if ($ownOnly) {
|
|
||||||
$meta = $this->loadFolderMetadata($folder);
|
header('Content-Type: application/json');
|
||||||
foreach ($files as $f) {
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
$bn = basename((string)$f);
|
header('Pragma: no-cache');
|
||||||
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
header('Expires: 0');
|
||||||
http_response_code(403); echo "Forbidden: you are not the owner of '{$bn}'."; return;
|
echo json_encode($out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function downloadZipFile()
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; }
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||||
|
if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; }
|
||||||
|
|
||||||
|
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||||
|
if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; }
|
||||||
|
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||||
|
@unlink($tokFile); // one-shot token
|
||||||
|
|
||||||
|
if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; }
|
||||||
|
$zip = (string)($job['zipPath'] ?? '');
|
||||||
|
$zipReal = realpath($zip);
|
||||||
|
$root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp');
|
||||||
|
if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; }
|
||||||
|
|
||||||
|
@session_write_close();
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1');
|
||||||
|
@ini_set('zlib.output_compression','0');
|
||||||
|
@ini_set('output_buffering','off');
|
||||||
|
while (ob_get_level()>0) @ob_end_clean();
|
||||||
|
|
||||||
|
@clearstatcache(true, $zipReal);
|
||||||
|
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip';
|
||||||
|
if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip';
|
||||||
|
$size = (int)@filesize($zipReal);
|
||||||
|
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Content-Disposition: attachment; filename="'.$name.'"');
|
||||||
|
if ($size>0) header('Content-Length: '.$size);
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
|
readfile($zipReal);
|
||||||
|
@unlink($zipReal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadZip()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; }
|
||||||
|
if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); return; }
|
||||||
|
|
||||||
|
$data = $this->_readJsonBody();
|
||||||
|
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
||||||
|
$this->_jsonOut(["error" => "Invalid input."], 400); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = $this->_normalizeFolder($data['folder']);
|
||||||
|
$files = $data['files'];
|
||||||
|
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
|
||||||
|
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$perms = $this->loadPerms($username);
|
||||||
|
|
||||||
|
// Optional zip gate by account flag
|
||||||
|
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||||
|
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ignoreOwnership = $this->isAdmin($perms)
|
||||||
|
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||||
|
|
||||||
|
// Ancestor-owner counts as full view
|
||||||
|
$fullView = $ignoreOwnership
|
||||||
|
|| ACL::canRead($username, $perms, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||||
|
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||||
|
|
||||||
|
if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; }
|
||||||
|
|
||||||
|
// If own-only, ensure all files are owned by the user
|
||||||
|
if ($ownOnly) {
|
||||||
|
$meta = $this->loadFolderMetadata($folder);
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$bn = basename((string)$f);
|
||||||
|
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
||||||
|
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = FileModel::createZipArchive($folder, $files);
|
|
||||||
if (isset($result['error'])) { http_response_code(400); echo $result['error']; return; }
|
|
||||||
|
|
||||||
$zipPath = $result['zipPath'] ?? null;
|
|
||||||
if (!$zipPath || !is_file($zipPath)) { http_response_code(500); echo "ZIP archive not found."; return; }
|
|
||||||
|
|
||||||
// ---- Clean binary stream setup ----
|
|
||||||
@session_write_close();
|
|
||||||
@set_time_limit(0);
|
|
||||||
@ignore_user_abort(true);
|
|
||||||
if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); }
|
|
||||||
@ini_set('zlib.output_compression', '0');
|
|
||||||
@ini_set('output_buffering', 'off');
|
|
||||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
|
||||||
|
|
||||||
@clearstatcache(true, $zipPath);
|
|
||||||
$size = (int)@filesize($zipPath);
|
|
||||||
|
|
||||||
header('X-Accel-Buffering: no');
|
|
||||||
header_remove('Content-Type');
|
|
||||||
header('Content-Type: application/zip');
|
|
||||||
// Client sets the final name via a.download in your JS; server can be generic
|
|
||||||
header('Content-Disposition: attachment; filename="files.zip"');
|
|
||||||
if ($size > 0) header('Content-Length: ' . $size);
|
|
||||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
|
||||||
header('Pragma: no-cache');
|
|
||||||
|
|
||||||
$fp = fopen($zipPath, 'rb');
|
|
||||||
if ($fp === false) { http_response_code(500); echo "Failed to open ZIP."; return; }
|
|
||||||
|
|
||||||
$chunk = 1048576; // 1 MiB
|
|
||||||
while (!feof($fp)) {
|
|
||||||
$buf = fread($fp, $chunk);
|
|
||||||
if ($buf === false) break;
|
|
||||||
echo $buf;
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
fclose($fp);
|
|
||||||
@unlink($zipPath);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
|
||||||
if (!headers_sent()) http_response_code(500);
|
|
||||||
echo "Internal server error while preparing ZIP.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||||
|
$tokDir = $root . DIRECTORY_SEPARATOR . '.tokens';
|
||||||
|
$logDir = $root . DIRECTORY_SEPARATOR . '.logs';
|
||||||
|
if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true);
|
||||||
|
if (!is_dir($logDir)) @mkdir($logDir, 0700, true);
|
||||||
|
@chmod($tokDir, 0700);
|
||||||
|
@chmod($logDir, 0700);
|
||||||
|
if (!is_dir($tokDir) || !is_writable($tokDir)) {
|
||||||
|
$this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light janitor: purge old tokens/logs > 6h (best-effort)
|
||||||
|
$now = time();
|
||||||
|
foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) {
|
||||||
|
if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); }
|
||||||
|
}
|
||||||
|
foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) {
|
||||||
|
if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user and global caps (simple anti-DoS)
|
||||||
|
$perUserCap = 2; // tweak if desired
|
||||||
|
$globalCap = 8; // tweak if desired
|
||||||
|
|
||||||
|
$tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||||
|
$mine = 0; $all = 0;
|
||||||
|
foreach ($tokens as $tf) {
|
||||||
|
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
|
||||||
|
$st = $job['status'] ?? 'unknown';
|
||||||
|
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
|
||||||
|
$all++;
|
||||||
|
if (($job['user'] ?? '') === $username) $mine++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; }
|
||||||
|
if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; }
|
||||||
|
|
||||||
|
// Create job token
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json';
|
||||||
|
$job = [
|
||||||
|
'user' => $username,
|
||||||
|
'folder' => $folder,
|
||||||
|
'files' => array_values($files),
|
||||||
|
'status' => 'queued',
|
||||||
|
'ctime' => time(),
|
||||||
|
'startedAt' => null,
|
||||||
|
'finalizeAt' => null,
|
||||||
|
'zipPath' => null,
|
||||||
|
'error' => null
|
||||||
|
];
|
||||||
|
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
|
$this->_jsonOut(["error"=>"Failed to create zip job."],500); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust spawn (detect php CLI, log, record PID)
|
||||||
|
$spawn = $this->spawnZipWorker($token, $tokFile, $logDir);
|
||||||
|
if (!$spawn['ok']) {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Spawn failed: '.$spawn['error'];
|
||||||
|
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
$this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->_jsonOut([
|
||||||
|
'ok' => true,
|
||||||
|
'token' => $token,
|
||||||
|
'status' => 'queued',
|
||||||
|
'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token),
|
||||||
|
'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token)
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||||
|
$this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function extractZip()
|
public function extractZip()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ private const OO_SUPPORTED_EXTS = [
|
|||||||
'ppt','pptx','odp',
|
'ppt','pptx','odp',
|
||||||
'pdf'
|
'pdf'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
|
||||||
|
private function effectiveFileOriginForDocs(): string
|
||||||
|
{
|
||||||
|
$cfg = AdminModel::getConfig();
|
||||||
|
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||||
|
|
||||||
|
// 1) explicit constant
|
||||||
|
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
|
||||||
|
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
|
||||||
|
}
|
||||||
|
// 2) admin.json setting
|
||||||
|
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
|
||||||
|
|
||||||
|
// 3) fallback: whatever the public sees (may hairpin, but still works)
|
||||||
|
return $this->effectivePublicOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
// Never editable via OO (we’ll always set edit=false for these)
|
// Never editable via OO (we’ll always set edit=false for these)
|
||||||
private const OO_NEVER_EDIT = ['pdf'];
|
private const OO_NEVER_EDIT = ['pdf'];
|
||||||
@@ -127,117 +144,119 @@ private function ooLog(string $level, string $msg): void
|
|||||||
|
|
||||||
/** GET /api/onlyoffice/status.php */
|
/** GET /api/onlyoffice/status.php */
|
||||||
public function status(): void
|
public function status(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
$enabled = $this->effectiveEnabled();
|
$enabled = $this->effectiveEnabled();
|
||||||
$docsOrig = $this->effectiveDocsOrigin();
|
$docsOrig = $this->effectiveDocsOrigin();
|
||||||
$secret = $this->effectiveSecret();
|
$secret = $this->effectiveSecret();
|
||||||
|
|
||||||
// Must have docs origin and secret to actually function
|
// Must have docs origin and secret to actually function
|
||||||
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
||||||
|
|
||||||
$exts = self::OO_SUPPORTED_EXTS;
|
$exts = self::OO_SUPPORTED_EXTS;
|
||||||
// If you want the extras:
|
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
||||||
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
|
||||||
|
echo json_encode([
|
||||||
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES);
|
'enabled' => (bool)$enabled,
|
||||||
}
|
'exts' => $exts,
|
||||||
|
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
|
||||||
|
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/onlyoffice/config.php?folder=...&file=... */
|
/** GET /api/onlyoffice/config.php?folder=...&file=... */
|
||||||
public function config(): void
|
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
|
||||||
{
|
public function config(): void
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
{
|
||||||
header('Cache-Control: no-store');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
@session_start();
|
@session_start();
|
||||||
$user = $_SESSION['username'] ?? 'anonymous';
|
$user = $_SESSION['username'] ?? 'anonymous';
|
||||||
$perms = [];
|
$perms = [];
|
||||||
$isAdmin = \ACL::isAdmin($perms);
|
$isAdmin = \ACL::isAdmin($perms);
|
||||||
|
|
||||||
// Effective toggles
|
$enabled = $this->effectiveEnabled();
|
||||||
$enabled = $this->effectiveEnabled();
|
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
||||||
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
$secret = $this->effectiveSecret();
|
||||||
$secret = $this->effectiveSecret();
|
|
||||||
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
|
|
||||||
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
|
|
||||||
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
|
|
||||||
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
|
|
||||||
|
|
||||||
// Inputs
|
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
|
||||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
|
||||||
$file = basename((string)($_GET['file'] ?? ''));
|
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
|
||||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
|
||||||
|
|
||||||
// ACL
|
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
$file = basename((string)($_GET['file'] ?? ''));
|
||||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||||
|
|
||||||
// Path
|
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
|
||||||
$abs = realpath($base . $rel . $file);
|
|
||||||
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
|
|
||||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
|
|
||||||
|
|
||||||
// Public origin
|
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||||
$publicOrigin = $this->effectivePublicOrigin();
|
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||||
|
$abs = realpath($base . $rel . $file);
|
||||||
|
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
|
||||||
|
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
|
||||||
|
|
||||||
// Signed download
|
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
|
||||||
$exp = time() + 10*60;
|
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
|
||||||
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
|
|
||||||
$sig = hash_hmac('sha256', $data, $secret, true);
|
|
||||||
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
|
|
||||||
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
|
||||||
|
|
||||||
// Callback
|
$exp = time() + 10*60;
|
||||||
$cbExp = time() + 10*60;
|
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
|
||||||
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
$sig = hash_hmac('sha256', $data, $secret, true);
|
||||||
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php'
|
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
|
||||||
. '?folder=' . rawurlencode($folder)
|
$fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
||||||
. '&file=' . rawurlencode($file)
|
|
||||||
. '&exp=' . $cbExp
|
|
||||||
. '&sig=' . $cbSig;
|
|
||||||
|
|
||||||
// Doc type & key
|
$cbExp = time() + 10*60;
|
||||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
||||||
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
|
||||||
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
. '?folder=' . rawurlencode($folder)
|
||||||
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
|
. '&file=' . rawurlencode($file)
|
||||||
|
. '&exp=' . $cbExp
|
||||||
|
. '&sig=' . $cbSig;
|
||||||
|
|
||||||
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
||||||
|
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
||||||
|
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
||||||
|
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
|
||||||
|
|
||||||
$cfgOut = [
|
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
||||||
'document' => [
|
|
||||||
'fileType' => $ext,
|
|
||||||
'key' => $key,
|
|
||||||
'title' => $file,
|
|
||||||
'url' => $fileUrl,
|
|
||||||
'permissions' => [
|
|
||||||
'download' => true,
|
|
||||||
'print' => true,
|
|
||||||
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'documentType' => $docType,
|
|
||||||
'editorConfig' => [
|
|
||||||
'callbackUrl' => $callbackUrl,
|
|
||||||
'user' => ['id'=>$user, 'name'=>$user],
|
|
||||||
'lang' => 'en',
|
|
||||||
],
|
|
||||||
'type' => 'desktop',
|
|
||||||
];
|
|
||||||
|
|
||||||
// JWT sign cfg
|
$cfgOut = [
|
||||||
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
|
'document' => [
|
||||||
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
'fileType' => $ext,
|
||||||
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
'key' => $key,
|
||||||
$cfgOut['token'] = "$h.$p.$s";
|
'title' => $file,
|
||||||
$cfgOut['docs_api_js'] = $docsApiJs;
|
'url' => $fileUrl,
|
||||||
|
'permissions' => [
|
||||||
|
'download' => true,
|
||||||
|
'print' => true,
|
||||||
|
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'documentType' => $docType,
|
||||||
|
'editorConfig' => [
|
||||||
|
'callbackUrl' => $callbackUrl,
|
||||||
|
'user' => ['id'=>$user, 'name'=>$user],
|
||||||
|
'lang' => 'en',
|
||||||
|
],
|
||||||
|
'type' => 'desktop',
|
||||||
|
];
|
||||||
|
|
||||||
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
// JWT sign cfg
|
||||||
}
|
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
|
||||||
|
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
||||||
|
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
||||||
|
$cfgOut['token'] = "$h.$p.$s";
|
||||||
|
|
||||||
|
// expose to client for preconnect/script load
|
||||||
|
$cfgOut['docs_api_js'] = $docsApiJs;
|
||||||
|
$cfgOut['documentServerOrigin'] = $docsOrigin;
|
||||||
|
|
||||||
|
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
||||||
public function callback(): void
|
public function callback(): void
|
||||||
@@ -343,41 +362,52 @@ private function ooLog(string $level, string $msg): void
|
|||||||
|
|
||||||
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
||||||
public function signedDownload(): void
|
public function signedDownload(): void
|
||||||
{
|
{
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
$secret = $this->effectiveSecret();
|
$secret = $this->effectiveSecret();
|
||||||
if ($secret === '') { http_response_code(403); return; }
|
if ($secret === '') { http_response_code(403); return; }
|
||||||
|
|
||||||
$tok = $_GET['tok'] ?? '';
|
$tok = $_GET['tok'] ?? '';
|
||||||
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
|
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
|
||||||
[$b64data, $b64sig] = explode('.', $tok, 2);
|
[$b64data, $b64sig] = explode('.', $tok, 2);
|
||||||
$data = $this->b64uDec($b64data);
|
$data = $this->b64uDec($b64data);
|
||||||
$sig = $this->b64uDec($b64sig);
|
$sig = $this->b64uDec($b64sig);
|
||||||
if ($data === false || $sig === false) { http_response_code(400); return; }
|
if ($data === false || $sig === false) { http_response_code(400); return; }
|
||||||
|
|
||||||
$calc = hash_hmac('sha256', $data, $secret, true);
|
$calc = hash_hmac('sha256', $data, $secret, true);
|
||||||
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
|
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
|
||||||
|
|
||||||
$payload = json_decode($data, true);
|
$payload = json_decode($data, true);
|
||||||
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
|
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
|
||||||
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
|
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
|
||||||
|
|
||||||
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
|
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
|
||||||
if ($folder === '' || $folder === 'root') $folder = 'root';
|
if ($folder === '' || $folder === 'root') $folder = 'root';
|
||||||
$file = basename((string)$payload['n']);
|
$file = basename((string)$payload['n']);
|
||||||
|
|
||||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||||
$abs = realpath($base . $rel . $file);
|
$abs = realpath($base . $rel . $file);
|
||||||
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
||||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
||||||
|
|
||||||
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
// Common headers
|
||||||
header('Content-Type: '.$mime);
|
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
||||||
header('Content-Length: '.filesize($abs));
|
$len = filesize($abs);
|
||||||
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
header('Content-Type: '.$mime);
|
||||||
readfile($abs);
|
header('Content-Length: '.$len);
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
||||||
|
header('Accept-Ranges: none'); // OO doesn’t require ranges; avoids partial edge-cases
|
||||||
|
|
||||||
|
// ---- Key change: for HEAD, do NOT read the file ----
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
|
||||||
|
// send headers only; no body
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET → stream the file
|
||||||
|
readfile($abs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
123
src/controllers/PortalController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,116 +5,143 @@ 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';
|
||||||
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
|
$requestParams = ($method === 'GET') ? $_GET : $_POST;
|
||||||
$received = '';
|
|
||||||
if (!empty($headersArr['x-csrf-token'])) {
|
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
|
||||||
$received = trim($headersArr['x-csrf-token']);
|
$isResumableTest =
|
||||||
} elseif (!empty($_POST['csrf_token'])) {
|
($method === 'GET'
|
||||||
$received = trim($_POST['csrf_token']);
|
&& isset($requestParams['resumableChunkNumber'])
|
||||||
} elseif (!empty($_POST['upload_token'])) {
|
&& isset($requestParams['resumableIdentifier']));
|
||||||
// legacy alias
|
|
||||||
$received = trim($_POST['upload_token']);
|
// ---- 1) CSRF (skip for resumable GET tests – Resumable only cares about HTTP status) ----
|
||||||
|
if (!$isResumableTest) {
|
||||||
|
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
|
||||||
|
$received = '';
|
||||||
|
|
||||||
|
if (!empty($headersArr['x-csrf-token'])) {
|
||||||
|
$received = trim($headersArr['x-csrf-token']);
|
||||||
|
} elseif (!empty($requestParams['csrf_token'])) {
|
||||||
|
$received = trim((string)$requestParams['csrf_token']);
|
||||||
|
} elseif (!empty($requestParams['upload_token'])) {
|
||||||
|
// legacy alias
|
||||||
|
$received = trim((string)$requestParams['upload_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
||||||
|
// Soft-fail so client can retry with refreshed token
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'csrf_expired' => true,
|
||||||
|
'csrf_token' => $_SESSION['csrf_token'],
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
|
||||||
// Soft-fail so client can retry with refreshed token
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
||||||
http_response_code(200);
|
|
||||||
echo json_encode([
|
|
||||||
'csrf_expired' => true,
|
|
||||||
'csrf_token' => $_SESSION['csrf_token']
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 2) Auth + account-level flags ----
|
// ---- 2) Auth + account-level flags ----
|
||||||
if (empty($_SESSION['authenticated'])) {
|
if (empty($_SESSION['authenticated'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = (string)($_SESSION['username'] ?? '');
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
$userPerms = loadUserPermissions($username) ?: [];
|
$userPerms = loadUserPermissions($username) ?: [];
|
||||||
$isAdmin = ACL::isAdmin($userPerms);
|
$isAdmin = ACL::isAdmin($userPerms);
|
||||||
|
|
||||||
// Admins should never be blocked by account-level "disableUpload"
|
// Admins should never be blocked by account-level "disableUpload"
|
||||||
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
|
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Upload disabled for this user.']);
|
echo json_encode(['error' => 'Upload disabled for this user.']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 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
|
||||||
$folderParam = rawurldecode($folderParam);
|
$folderParam = rawurldecode($folderParam);
|
||||||
$targetFolder = ACL::normalizeFolder($folderParam);
|
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||||
|
|
||||||
// Admins bypass folder canWrite checks
|
// Admins bypass folder canWrite checks
|
||||||
$username = (string)($_SESSION['username'] ?? '');
|
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
||||||
$userPerms = loadUserPermissions($username) ?: [];
|
http_response_code(403);
|
||||||
$isAdmin = ACL::isAdmin($userPerms);
|
echo json_encode([
|
||||||
|
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
// ---- 4) Delegate to model (force the sanitized folder) ----
|
||||||
http_response_code(403);
|
$requestParams['folder'] = $targetFolder;
|
||||||
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
// Keep legacy behavior for anything still reading $_POST directly
|
||||||
return;
|
$_POST['folder'] = $targetFolder;
|
||||||
|
|
||||||
|
$result = UploadModel::handleUpload($requestParams, $_FILES);
|
||||||
|
|
||||||
|
// ---- 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'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($result['status'])) {
|
||||||
|
echo json_encode($result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => $result['success'] ?? 'File uploaded successfully',
|
||||||
|
'newFilename' => $result['newFilename'] ?? null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 4) Delegate to model (force the sanitized folder) ----
|
public function removeChunks(): void
|
||||||
$_POST['folder'] = $targetFolder; // in case model reads superglobal
|
{
|
||||||
$post = $_POST;
|
header('Content-Type: application/json');
|
||||||
$post['folder'] = $targetFolder;
|
|
||||||
|
|
||||||
$result = UploadModel::handleUpload($post, $_FILES);
|
$receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
|
||||||
|
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 5) Response (unchanged) ----
|
if (!isset($_POST['folder'])) {
|
||||||
if (isset($result['error'])) {
|
http_response_code(400);
|
||||||
http_response_code(400);
|
echo json_encode(['error' => 'No folder specified']);
|
||||||
echo json_encode($result);
|
return;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
$folderRaw = (string)$_POST['folder'];
|
||||||
|
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
|
||||||
|
|
||||||
|
echo json_encode(UploadModel::removeChunks($folder));
|
||||||
}
|
}
|
||||||
if (isset($result['status'])) {
|
|
||||||
echo json_encode($result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => 'File uploaded successfully',
|
|
||||||
'newFilename' => $result['newFilename'] ?? null
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeChunks(): void {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
|
||||||
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($_POST['folder'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'No folder specified']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$folderRaw = (string)$_POST['folder'];
|
|
||||||
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
|
|
||||||
|
|
||||||
echo json_encode(UploadModel::removeChunks($folder));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
568
src/lib/ACL.php
@@ -10,23 +10,38 @@ class ACL
|
|||||||
private static $path = null;
|
private static $path = null;
|
||||||
|
|
||||||
private const BUCKETS = [
|
private const BUCKETS = [
|
||||||
'owners','read','write','share','read_own',
|
'owners',
|
||||||
'create','upload','edit','rename','copy','move','delete','extract',
|
'read',
|
||||||
'share_file','share_folder'
|
'write',
|
||||||
|
'share',
|
||||||
|
'read_own',
|
||||||
|
'create',
|
||||||
|
'upload',
|
||||||
|
'edit',
|
||||||
|
'rename',
|
||||||
|
'copy',
|
||||||
|
'move',
|
||||||
|
'delete',
|
||||||
|
'extract',
|
||||||
|
'share_file',
|
||||||
|
'share_folder'
|
||||||
];
|
];
|
||||||
|
|
||||||
private static function path(): string {
|
private static function path(): string
|
||||||
|
{
|
||||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
return self::$path;
|
return self::$path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function normalizeFolder(string $f): string {
|
public static function normalizeFolder(string $f): string
|
||||||
|
{
|
||||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||||
if ($f === '' || $f === 'root') return 'root';
|
if ($f === '' || $f === 'root') return 'root';
|
||||||
return $f;
|
return $f;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function purgeUser(string $user): bool {
|
public static function purgeUser(string $user): bool
|
||||||
|
{
|
||||||
$user = (string)$user;
|
$user = (string)$user;
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
$changed = false;
|
$changed = false;
|
||||||
@@ -41,49 +56,107 @@ class ACL
|
|||||||
return $changed ? self::save($acl) : true;
|
return $changed ? self::save($acl) : true;
|
||||||
}
|
}
|
||||||
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||||
{
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||||
|
|
||||||
$folder = trim($folder, "/\\ ");
|
$folder = trim($folder, "/\\ ");
|
||||||
if ($folder === '' || $folder === 'root') return false;
|
if ($folder === '' || $folder === 'root') return false;
|
||||||
|
|
||||||
$parts = explode('/', $folder);
|
$parts = explode('/', $folder);
|
||||||
while (count($parts) > 1) {
|
while (count($parts) > 1) {
|
||||||
array_pop($parts);
|
array_pop($parts);
|
||||||
$parent = implode('/', $parts);
|
$parent = implode('/', $parts);
|
||||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function migrateSubtree(string $source, string $target): array
|
||||||
|
{
|
||||||
|
// PHP <8 polyfill
|
||||||
|
if (!function_exists('str_starts_with')) {
|
||||||
|
function str_starts_with(string $h, string $n): bool
|
||||||
|
{
|
||||||
|
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = self::normalizeFolder($source);
|
||||||
|
$dst = self::normalizeFolder($target);
|
||||||
|
if ($src === 'root') return ['changed' => false, 'moved' => 0];
|
||||||
|
|
||||||
|
$file = self::path(); // e.g. META_DIR/folder_acl.json
|
||||||
|
$raw = @file_get_contents($file);
|
||||||
|
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||||
|
if (!is_array($map)) $map = [];
|
||||||
|
|
||||||
|
$prefix = $src;
|
||||||
|
$needle = $src . '/';
|
||||||
|
|
||||||
|
$new = $map;
|
||||||
|
$changed = false;
|
||||||
|
$moved = 0;
|
||||||
|
|
||||||
|
foreach ($map as $key => $entry) {
|
||||||
|
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
|
||||||
|
if (!$isMatch) continue;
|
||||||
|
|
||||||
|
unset($new[$key]);
|
||||||
|
|
||||||
|
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
|
||||||
|
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
|
||||||
|
|
||||||
|
// keep only known buckets (defensive)
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$clean = [];
|
||||||
|
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
|
||||||
|
$entry = $clean ?: $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwrite any existing entry at destination path (safer than union)
|
||||||
|
$new[$newKey] = $entry;
|
||||||
|
$changed = true;
|
||||||
|
$moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
@chmod($file, 0664);
|
||||||
|
self::$cache = $new; // keep in-process cache fresh if you use it
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['changed' => $changed, 'moved' => $moved];
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||||
{
|
{
|
||||||
$old = self::normalizeFolder($oldFolder);
|
$old = self::normalizeFolder($oldFolder);
|
||||||
$new = self::normalizeFolder($newFolder);
|
$new = self::normalizeFolder($newFolder);
|
||||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||||
|
|
||||||
$acl = self::$cache ?? self::loadFresh();
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||||
|
|
||||||
$rebased = [];
|
$rebased = [];
|
||||||
foreach ($acl['folders'] as $k => $rec) {
|
foreach ($acl['folders'] as $k => $rec) {
|
||||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||||
$suffix = substr($k, strlen($old));
|
$suffix = substr($k, strlen($old));
|
||||||
$suffix = ltrim((string)$suffix, '/');
|
$suffix = ltrim((string)$suffix, '/');
|
||||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||||
$rebased[$newKey] = $rec;
|
$rebased[$newKey] = $rec;
|
||||||
} else {
|
} else {
|
||||||
$rebased[$k] = $rec;
|
$rebased[$k] = $rec;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$acl['folders'] = $rebased;
|
||||||
|
self::save($acl);
|
||||||
}
|
}
|
||||||
$acl['folders'] = $rebased;
|
|
||||||
self::save($acl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function loadFresh(): array {
|
private static function loadFresh(): array
|
||||||
|
{
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
@mkdir(dirname($path), 0755, true);
|
@mkdir(dirname($path), 0755, true);
|
||||||
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
'read' => ['admin'],
|
'read' => ['admin'],
|
||||||
'write' => ['admin'],
|
'write' => ['admin'],
|
||||||
'share' => ['admin'],
|
'share' => ['admin'],
|
||||||
'read_own'=> [],
|
'read_own' => [],
|
||||||
'create' => [],
|
'create' => [],
|
||||||
'upload' => [],
|
'upload' => [],
|
||||||
'edit' => [],
|
'edit' => [],
|
||||||
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
|
|
||||||
$healed = false;
|
$healed = false;
|
||||||
foreach ($data['folders'] as $folder => &$rec) {
|
foreach ($data['folders'] as $folder => &$rec) {
|
||||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
if (!is_array($rec)) {
|
||||||
|
$rec = [];
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
$v = $rec[$k] ?? [];
|
$v = $rec[$k] ?? [];
|
||||||
if (!is_array($v)) { $v = []; $healed = true; }
|
if (!is_array($v)) {
|
||||||
|
$v = [];
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
$v = array_values(array_unique(array_map('strval', $v)));
|
$v = array_values(array_unique(array_map('strval', $v)));
|
||||||
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
if (($rec[$k] ?? null) !== $v) {
|
||||||
|
$rec[$k] = $v;
|
||||||
|
$healed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($rec);
|
unset($rec);
|
||||||
@@ -145,19 +227,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;
|
||||||
$arr = self::listFor($folder, $capKey);
|
|
||||||
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
|
// 1) Core per-folder ACL buckets (folder_acl.json)
|
||||||
|
$arr = self::listFor($folder, $capKey);
|
||||||
|
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,19 +584,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
return self::save($acl);
|
return self::save($acl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
public static function applyUserGrantsAtomic(string $user, array $grants): array
|
||||||
|
{
|
||||||
$user = (string)$user;
|
$user = (string)$user;
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
|
|
||||||
$fh = @fopen($path, 'c+');
|
$fh = @fopen($path, 'c+');
|
||||||
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
if (!flock($fh, LOCK_EX)) {
|
||||||
|
fclose($fh);
|
||||||
|
throw new RuntimeException('Cannot lock ACL storage');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$raw = stream_get_contents($fh);
|
$raw = stream_get_contents($fh);
|
||||||
if ($raw === false) $raw = '';
|
if ($raw === false) $raw = '';
|
||||||
$acl = json_decode($raw, true);
|
$acl = json_decode($raw, true);
|
||||||
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||||
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||||
|
|
||||||
@@ -335,7 +609,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
foreach ($grants as $folder => $caps) {
|
foreach ($grants as $folder => $caps) {
|
||||||
$ff = self::normalizeFolder((string)$folder);
|
$ff = self::normalizeFolder((string)$folder);
|
||||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||||
$rec =& $acl['folders'][$ff];
|
$rec = &$acl['folders'][$ff];
|
||||||
|
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||||
@@ -365,10 +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;
|
||||||
@@ -385,7 +665,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
if ($dl) $rec['delete'][] = $user;
|
if ($dl) $rec['delete'][] = $user;
|
||||||
if ($ex) $rec['extract'][] = $user;
|
if ($ex) $rec['extract'][] = $user;
|
||||||
if ($sf) $rec['share_file'][] = $user;
|
if ($sf) $rec['share_file'][] = $user;
|
||||||
if ($sfo)$rec['share_folder'][] = $user;
|
if ($sfo) $rec['share_folder'][] = $user;
|
||||||
|
|
||||||
foreach (self::BUCKETS as $k) {
|
foreach (self::BUCKETS as $k) {
|
||||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||||
@@ -409,90 +689,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Granular write family -----------------------------------------------
|
// --- Granular write family -----------------------------------------------
|
||||||
|
|
||||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
public static function canCreate(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'create')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'create')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
public static function canCreateFolder(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
// Only owners/managers can create subfolders under $folder
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners');
|
// Only owners/managers can create subfolders under $folder
|
||||||
}
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
public static function canUpload(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'upload')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'upload')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
public static function canEdit(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'edit')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'edit')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
public static function canRename(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'rename')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'rename')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
public static function canCopy(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'copy')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'copy')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
public static function canMove(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
if (self::isAdmin($perms)) return true;
|
||||||
}
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
public static function canMoveFolder(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
if (self::isAdmin($perms)) return true;
|
||||||
}
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
public static function canDelete(string $user, array $perms, string $folder): bool
|
||||||
$folder = self::normalizeFolder($folder);
|
{
|
||||||
if (self::isAdmin($perms)) return true;
|
$folder = self::normalizeFolder($folder);
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
if (self::isAdmin($perms)) return true;
|
||||||
|| self::hasGrant($user, $folder, 'delete')
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|| self::hasGrant($user, $folder, 'delete')
|
||||||
}
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canExtract(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'extract')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
|
||||||
$folder = self::normalizeFolder($folder);
|
|
||||||
if (self::isAdmin($perms)) return true;
|
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
|
||||||
|| self::hasGrant($user, $folder, 'extract')
|
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sharing: files use share, folders require share + full-view. */
|
/** Sharing: files use share, folders require share + full-view. */
|
||||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
public static function canShareFile(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
}
|
}
|
||||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
public static function canShareFolder(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
@@ -500,4 +792,4 @@ public static function canExtract(string $user, array $perms, string $folder): b
|
|||||||
// require full view too
|
// require full view too
|
||||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
87
src/lib/FS.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
// src/lib/FS.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
|
final class FS
|
||||||
|
{
|
||||||
|
/** Hidden/system names to ignore entirely */
|
||||||
|
public static function IGNORE(): array {
|
||||||
|
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App-specific names to skip from UI */
|
||||||
|
public static function SKIP(): array {
|
||||||
|
return ['trash','profile_pics'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSafeSegment(string $name): bool {
|
||||||
|
if ($name === '.' || $name === '..') return false;
|
||||||
|
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
|
||||||
|
if (strpos($name, "\0") !== false) return false;
|
||||||
|
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
|
||||||
|
$len = mb_strlen($name);
|
||||||
|
return $len > 0 && $len <= 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
|
||||||
|
public static function safeReal(string $baseReal, string $p): ?string {
|
||||||
|
$rp = realpath($p);
|
||||||
|
if ($rp === false) return null;
|
||||||
|
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||||
|
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||||
|
if (strpos($rp2, $base) !== 0) return null;
|
||||||
|
return rtrim($rp, DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
|
||||||
|
* $maxDepth intentionally small to avoid expensive scans.
|
||||||
|
*/
|
||||||
|
public static function hasReadableDescendant(
|
||||||
|
string $baseReal,
|
||||||
|
string $absPath,
|
||||||
|
string $relPath,
|
||||||
|
string $user,
|
||||||
|
array $perms,
|
||||||
|
int $maxDepth = 2
|
||||||
|
): bool {
|
||||||
|
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
|
||||||
|
|
||||||
|
$IGNORE = self::IGNORE();
|
||||||
|
$SKIP = self::SKIP();
|
||||||
|
|
||||||
|
$items = @scandir($absPath) ?: [];
|
||||||
|
foreach ($items as $child) {
|
||||||
|
if ($child === '.' || $child === '..') continue;
|
||||||
|
if ($child[0] === '.') continue;
|
||||||
|
if (in_array($child, $IGNORE, true)) continue;
|
||||||
|
if (!self::isSafeSegment($child)) continue;
|
||||||
|
|
||||||
|
$lower = strtolower($child);
|
||||||
|
if (in_array($lower, $SKIP, true)) continue;
|
||||||
|
|
||||||
|
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
|
||||||
|
if (!@is_dir($abs)) continue;
|
||||||
|
|
||||||
|
// Resolve symlink safely
|
||||||
|
if (@is_link($abs)) {
|
||||||
|
$safe = self::safeReal($baseReal, $abs);
|
||||||
|
if ($safe === null || !is_dir($safe)) continue;
|
||||||
|
$abs = $safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
|
||||||
|
|
||||||
|
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
// Don’t 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
|
||||||
@@ -104,16 +137,17 @@ class AdminModel
|
|||||||
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
||||||
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
||||||
|
|
||||||
if ($locked) {
|
if ($locked) {
|
||||||
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
||||||
} else {
|
} else {
|
||||||
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||||
|
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
|
||||||
|
|
||||||
return $public;
|
return $public;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
||||||
public static function writeSiteConfig(array $publicSubset): array
|
public static function writeSiteConfig(array $publicSubset): array
|
||||||
@@ -226,6 +260,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);
|
||||||
@@ -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' => '',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||