Compare commits

...

109 Commits

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

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
Ryan
a031fc99c2 release(ci): harden release-on-version workflow; remove sleep/race, safer checkout, deterministic ref 2025-11-10 03:01:46 -05:00
github-actions[bot]
db73cf2876 chore(release): set APP_VERSION to v1.9.2 [skip ci] 2025-11-10 07:50:29 +00:00
Ryan
062f34dd3d release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback) 2025-11-10 02:50:19 -05:00
Ryan
63b24ba698 chore(doc): readme adjustments for webdav & security 2025-11-09 21:59:20 -05:00
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
Ryan
8f03cc7456 release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client 2025-11-08 13:53:11 -05:00
github-actions[bot]
c9a99506d7 chore(release): set APP_VERSION to v1.8.10 [skip ci] 2025-11-08 18:33:52 +00:00
Ryan
04ec0a0830 release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul 2025-11-08 13:33:38 -05:00
github-actions[bot]
429cd0314a chore(release): set APP_VERSION to v1.8.9 [skip ci] 2025-11-08 03:10:24 +00:00
Ryan
ba29cc4822 release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64) 2025-11-07 22:10:14 -05:00
github-actions[bot]
e2cd304158 chore(release): set APP_VERSION to v1.8.8 [skip ci] 2025-11-07 07:57:42 +00:00
Ryan
ca8788a694 release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60 2025-11-07 02:57:30 -05:00
Ryan
dc45fed886 chore(ci): increase release delay to 10m to avoid ref replication race 2025-11-05 00:19:56 -05:00
github-actions[bot]
a9fe342175 chore(release): set APP_VERSION to v1.8.7 [skip ci] 2025-11-05 05:02:42 +00:00
Ryan
7669f5a10b release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives 2025-11-05 00:02:32 -05:00
Ryan
34a4e06a23 chore(ci): add manual trigger + bot-derived version detection for releases 2025-11-04 23:09:31 -05:00
github-actions[bot]
d00faf5fe7 chore(release): set APP_VERSION to v1.8.6 [skip ci] 2025-11-05 03:57:04 +00:00
Ryan
ad8cbc601a release(v1.8.6): fix large ZIP downloads + safer extract; close #60 2025-11-04 22:56:53 -05:00
Ryan
40e000b5bc chore(ci): release uses correct commit for version.js + harden workflow_run 2025-11-04 22:22:24 -05:00
Ryan
eee25a4dc6 ci: revert but keep delay 2025-11-04 22:04:15 -05:00
github-actions[bot]
d66f4d93cb chore(release): set APP_VERSION to v1.8.5 [skip ci] 2025-11-05 02:17:05 +00:00
Ryan
f4f7f8ef38 release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing needs: delay, final test 2025-11-04 21:16:55 -05:00
github-actions[bot]
0ccba45c40 chore(release): set APP_VERSION to v1.8.4 [skip ci] 2025-11-05 02:09:07 +00:00
Ryan
620c916eb3 release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races 2025-11-04 21:08:58 -05:00
github-actions[bot]
f809cc09d2 chore(release): set APP_VERSION to v1.8.3 [skip ci] 2025-11-05 01:58:42 +00:00
Ryan
6758b5f73d release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust 2025-11-04 20:58:34 -05:00
github-actions[bot]
30a0aaf05e chore(release): set APP_VERSION to v1.8.2 [skip ci] 2025-11-05 01:34:51 +00:00
Ryan
c843f00738 release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37) 2025-11-04 20:34:42 -05:00
github-actions[bot]
4bb9d81370 chore(release): set APP_VERSION to v1.8.1 [skip ci] 2025-11-03 21:59:58 +00:00
Ryan
29e0497730 release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder 2025-11-03 16:59:47 -05:00
github-actions[bot]
dd3a7a5145 chore(release): set APP_VERSION to v1.8.0 [skip ci] 2025-11-03 21:40:02 +00:00
Ryan
d00db803c3 release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
2025-11-03 16:39:48 -05:00
137 changed files with 26556 additions and 7608 deletions

View File

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

File diff suppressed because it is too large Load Diff

514
README.md
View File

@@ -7,172 +7,107 @@
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
[![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases)
[![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join_chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/7WN6f56X2e)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [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.
Upload, organize, and share files or folders through a sleek, responsive web interface.
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
- 💾 **Self-hosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external DB required.
- 🔐 **Granular per-folder ACLs** View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
- 🔄 **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.
- 📊 **Storage / disk usage summary** CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
- 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
- 👥 **Pro: user groups, client portals & storage explorer** Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
**10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:**
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
> 💡 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).
---
## 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.
- 🗂️ **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.
- 🗃️ **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.
- 🔐 **Granular Access Control (ACL):**
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.
- 🏷️ **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:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
- [Features overview](https://github.com/error311/FileRise/wiki/Features)
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
---
## Live Demo
## 1. What FileRise does
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
**Demo credentials:** `demo` / `demo`
FileRise turns a folder on your server into a **web-based file explorer** with:
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
docker pull error311/filerise-docker:latest
```
#### Run a container
### Option A Quick start (docker run)
```bash
docker run -d \
--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 TOTAL_UPLOAD_SIZE="10G" \
-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="" \
-e CHOWN_ON_START="true" \
-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.
Then visit:
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
**Notes**
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
- `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)
```text
http://your-server-ip:8080
```
#### Using Docker Compose
On first launch youll be guided through creating the **initial admin user**.
Save as `docker-compose.yml`, then `docker-compose up -d`:
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you dont want a recursive `chown` on every start.
> ⚠️ **Uploads folder recommendation**
>
> Its strongly recommended to bind `/var/www/uploads` to a **dedicated folder**
> (for example `~/filerise/uploads` or `/mnt/user/appdata/FileRise/uploads`),
> not the root of a huge media share.
>
> If you really want FileRise to sit “on top of” an existing share, use a
> subfolder (e.g. `/mnt/user/media/filerise_root`) instead of the share root,
> so scans and permission changes stay scoped to that folder.
---
### Option B docker-compose.yml
```yaml
services:
@@ -182,252 +117,177 @@ services:
ports:
- "8080:80"
environment:
TIMEZONE: "UTC"
DATE_TIME_FORMAT: "m/d/y h:iA"
TIMEZONE: "America/New_York"
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: ""
SCAN_ON_START: "true" # auto-index existing files on startup
CHOWN_ON_START: "true" # fix permissions on uploads/users/metadata on startup
volumes:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
restart: unless-stopped
```
Access at `http://localhost:8080` (or your servers IP).
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
Bring it up with:
-`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
**First-time Setup**
On first launch, if no users exist, youll be prompted to create an **Admin account**. Then use **User Management** to add more users.
```bash
docker compose up -d
```
---
### 2) Manual Installation (PHP/Apache)
### Common environment variables
If you prefer a traditional web server (LAMP stack or similar):
| Variable | Required | Example | What it does |
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
> (currently `m/d/y h:iA`).
> 🗂 **Using an existing folder tree**
>
> - Point `/var/www/uploads` at the folder you want FileRise to manage.
> - Set `SCAN_ON_START="true"` on the first run to index existing files, then
> usually set it to `"false"` so the container doesnt rescan on every restart.
> - `CHOWN_ON_START="true"` is handy on first run to fix permissions. If you map
> a large share or already manage ownership yourself, set it to `"false"` to
> avoid recursive `chown` on every start.
>
> Volumes:
> - `/var/www/uploads` your actual files
> - `/var/www/users` user & pro jsons
> - `/var/www/metadata` tags, search index, share links, etc.
**More Docker / orchestration options (Unraid, Portainer, k8s, reverse proxy, etc.)**
- [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
- [Nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
- [FAQ](https://github.com/error311/FileRise/wiki/FAQ)
- [Kubernetes / k8s deployment](https://github.com/error311/FileRise/wiki/Kubernetes---k8s-deployment)
- Portainer templates: add this URL in Portainer → Settings → App Templates:
`https://raw.githubusercontent.com/error311/filerise-portainer-templates/refs/heads/main/templates.json`
- See also the Docker repo: [error311/filerise-docker](https://github.com/error311/filerise-docker)
---
## 3. Manual install (PHP web server)
Prefer bare-metal or your own stack? FileRise is just PHP + a few extensions.
**Requirements**
- PHP **8.3+**
- Apache (mod_php) or another web server configured for PHP
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
- Web server (Apache / Nginx / Caddy + PHP-FPM)
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
- No database required
**Download Files**
**Steps**
```bash
git clone https://github.com/error311/FileRise.git
```
1. Clone or download FileRise into your web root:
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)**
2. Create data directories and set permissions:
```bash
composer install
```
```bash
cd FileRise
mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # adjust for your web user
chmod -R 775 uploads users metadata
```
**Folders & Permissions**
3. (Optional) Install PHP dependencies with Composer:
```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
```
```bash
composer install
```
- `uploads/`: actual files
- `users/`: credentials & token storage
- `metadata/`: file metadata (tags, share links, etc.)
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).
**Configuration**
5. Browse to your FileRise URL and follow the **admin setup** screen.
Edit `config.php`:
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
**Share link base URL**
- Set **`SHARE_URL`** via web-server env vars (preferred),
**or** keep using `BASE_URL` in `config.php` as a fallback.
- If neither is set, FileRise auto-detects from the current host/scheme.
**Web server config**
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
Browse to your FileRise URL; youll be prompted to create the Admin user on first load.
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
---
### 3) Admins
## 4. WebDAV & ONLYOFFICE (optional)
> **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.
### WebDAV
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
- **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.
WebDAV operations honor the same ACLs as the web UI.
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
### ONLYOFFICE integration
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).
Configure it in **Admin → ONLYOFFICE**:
- 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
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
---
## Unraid
## 5. Security & updates
- 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.
- 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**.
---
## Upgrade
## 6. Community, support & contributing
```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
```
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
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)
---
## Quick-start: Mount via WebDAV
## 7. License & third-party code
Once FileRise is running, enable WebDAV in the admin panel.
FileRise Core is released under the **MIT License** see [LICENSE](LICENSE).
```bash
# Linux (GVFS/GIO)
gio mount dav://demo@your-host/webdav.php/
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.
# macOS (Finder → Go → Connect to Server…)
https://your-host/webdav.php/
```
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
## 8. Press
### 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.
---
## FAQ / Troubleshooting
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHPs `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
- **How to enable HTTPS?** FileRise doesnt terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
- **Changing Admin or resetting password:** Admin can change any users password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
For more Q&A or to ask for help, open a Discussion or Issue.
---
## Security posture
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
If youre running ≤1.4.x, please upgrade.
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
---
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
Areas to help: translations, bug fixes, UI polish, integrations.
If you like FileRise, a ⭐ star on GitHub is much appreciated!
---
## 💖 Sponsor FileRise
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
---
## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues.
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
[![Star History Chart](https://api.star-history.com/svg?repos=error311/FileRise&type=Date)](https://star-history.com/#error311/FileRise&Date)
---
## Dependencies
### 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
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
---
## License & Credits
MIT License see [LICENSE](LICENSE).
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
- [Heise / iX Magazin “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
- [Heise / iX Magazin “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
// config.php
// 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('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
define('FR_DEMO_MODE', false);
date_default_timezone_set(TIMEZONE);
@@ -25,6 +27,17 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
define('ACL_INHERIT_ON_CREATE', true);
// ONLYOFFICE integration overrides (uncomment and set as needed)
/*
define('ONLYOFFICE_ENABLED', false);
define('ONLYOFFICE_JWT_SECRET', 'test123456');
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
define('ONLYOFFICE_DEBUG', true);
*/
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
}
// Encryption helpers
function encryptData($data, $encryptionKey)
@@ -89,10 +102,15 @@ $secure = ($envSecure !== false)
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
: (!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
$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
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
/**
* Start session idempotently:
@@ -143,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
if (!empty($tokens[$token])) {
$data = $tokens[$token];
if ($data['expiry'] >= time()) {
// NEW: mitigate session fixation
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $data["username"];
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
@@ -150,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
} else {
// expired — clean up
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);
}
}
@@ -227,4 +254,59 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
}
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
// ------------------------------------------------------------
// FileRise Pro bootstrap wiring
// ------------------------------------------------------------
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
if (!defined('FR_PRO_LICENSE')) {
$envLicense = getenv('FR_PRO_LICENSE');
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
}
// JSON license file used by AdminController::setLicense()
if (!defined('PRO_LICENSE_FILE')) {
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
}
// Optional plain-text license file (used as fallback in bootstrap)
if (!defined('FR_PRO_LICENSE_FILE')) {
$lf = getenv('FR_PRO_LICENSE_FILE');
if ($lf === false || $lf === '') {
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
}
define('FR_PRO_LICENSE_FILE', $lf);
}
// Where Pro code lives by default → inside users volume
$proDir = getenv('FR_PRO_BUNDLE_DIR');
if ($proDir === false || $proDir === '') {
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
}
$proDir = rtrim($proDir, "/\\");
if (!defined('FR_PRO_BUNDLE_DIR')) {
define('FR_PRO_BUNDLE_DIR', $proDir);
}
// Try to load Pro bootstrap if enabled + present
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
if (@is_file($proBootstrap)) {
require_once $proBootstrap;
}
// If bootstrap didnt define these, give safe defaults
if (!defined('FR_PRO_ACTIVE')) {
define('FR_PRO_ACTIVE', false);
}
if (!defined('FR_PRO_INFO')) {
define('FR_PRO_INFO', [
'valid' => false,
'error' => null,
'payload' => null,
]);
}
if (!defined('FR_PRO_BUNDLE_VERSION')) {
define('FR_PRO_BUNDLE_VERSION', null);
}

View File

@@ -1,38 +1,64 @@
# --------------------------------
# Base: safe in most environments
# FileRise portable .htaccess
# --------------------------------
Options -Indexes
Options -Indexes -Multiviews
DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c>
<FilesMatch "^\.">
# Block direct access to dotfiles like .env, .gitignore, etc.
<FilesMatch "^\..*">
Require all denied
</FilesMatch>
</IfModule>
# ---------------- Rewrites ----------------
<IfModule mod_rewrite.c>
RewriteEngine On
# Never redirect local/dev hosts
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
RewriteRule - - [L]
# 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]
# --- HTTPS redirect ---
# Use ONE of these blocks.
# 4) HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
#RewriteCond %{HTTPS} off
# A) Direct TLS on this server
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto
# B) Behind reverse proxy that sets X-Forwarded-Proto
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Don't interfere with ACME/http-01 if you do your own certs
#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
#RewriteRule - - [L]
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
RewriteRule ^ - [E=IS_VER:1]
</IfModule>
# --- MIME types (fonts/SVG/ESM) ---
# ---------------- MIME types ----------------
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
@@ -40,7 +66,7 @@ RewriteRule ^ - [L]
AddType application/javascript .mjs
</IfModule>
# --- Security headers ---
# ---------------- Security headers ----------------
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
@@ -51,59 +77,53 @@ RewriteRule ^ - [L]
Header always set Expect-CT "max-age=86400, enforce"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# HSTS only when actually on HTTPS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
# CSP (modules, blobs, workers, etc.)
# HSTS only when HTTPS (safe for .htaccess)
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
# CSP — keep this SHA-256 in sync with your inline pre-theme script
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='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule>
# --- Caching (query-string based, no env vars needed) ---
# ---------------- Caching ----------------
<IfModule mod_headers.c>
# HTML/PHP: no cache (only if PHP didnt already set it)
# HTML/PHP: no cache
<FilesMatch "\.(html?|php)$">
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header setifempty Pragma "no-cache"
Header setifempty Expires "0"
</FilesMatch>
# version.js: always non-cacheable
# version.js: never cache
<FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
# Unversioned JS/CSS: 1 hour
# JS/CSS: long cache if ?v= present, else 1h
<FilesMatch "\.(?:m?js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
</FilesMatch>
# Unversioned static (images/fonts): 7 days
# Images/fonts: long cache if ?v= present, else 7d
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=604800" env=!IS_VER
</FilesMatch>
# --- Versioned assets (?v=...) : 1 year + immutable (override anything else) ---
<IfModule mod_headers.c>
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
# Only when query string has v=
Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
</FilesMatch>
</IfModule>
</IfModule>
# --- Compression ---
# ---------------- Compression ----------------
<IfModule mod_brotli.c>
BrotliCompressionQuality 5
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
# --- Disable TRACE ---
# ---------------- Disable TRACE ----------------
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} ^TRACE
RewriteRule .* - [F]
RewriteRule .* - [F]
</IfModule>

View File

@@ -3,83 +3,26 @@
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json');
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'] ?? ''));
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 {
$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 */ }
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);
$ctrl = new AclAdminController();
$grants = $ctrl->getUserGrants($user);
echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
}

View File

@@ -3,12 +3,11 @@
declare(strict_types=1);
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();
header('Content-Type: application/json');
// ---- Auth + CSRF -----------------------------------------------------------
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
@@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
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');
$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);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
// ---- Single user mode: { user, grants } ------------------------------------
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
$user = trim((string)$in['user']);
if (!valid_user($user)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid user']);
exit;
}
$grants = sanitize_grants_map($in['grants']);
try {
$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}]}']);
echo json_encode(['error' => $e->getMessage()]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
}

View File

@@ -0,0 +1,41 @@
<?php
// public/api/admin/diskUsageSummary.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
$authenticated = !empty($_SESSION['authenticated']);
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if (!$authenticated || !$isAdmin) {
http_response_code(401);
echo json_encode([
'ok' => false,
'error' => 'Unauthorized',
]);
exit;
}
// Optional tuning via query params
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
try {
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
http_response_code($summary['ok'] ? 200 : 404);
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,102 @@
<?php
// public/api/admin/diskUsageTriggerScan.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock early so the scanner/other requests aren't blocked
@session_write_close();
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
// Pro-only gate removed so free FileRise can also use the Rescan button.
/*
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
*/
try {
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
if (!$worker || !is_file($worker)) {
throw new RuntimeException('disk_usage_scan.php not found.');
}
// Find a PHP CLI binary that actually works (same idea as zip_worker)
$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', $out, $rc);
if ($rc === 0) {
$php = $bin;
break;
}
}
if (!$php) {
throw new RuntimeException('No working php CLI found.');
}
$meta = rtrim((string)META_DIR, '/\\');
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
@mkdir($logDir, 0775, true);
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
$cmdStr =
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
$pid = is_string($pid) ? (int)trim($pid) : 0;
http_response_code(200);
echo json_encode([
'ok' => true,
'pid' => $pid > 0 ? $pid : null,
'message' => 'Disk usage scan started in the background.',
'logFile' => $logFile,
], JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/getProgress.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->getProgress();

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/getViewedMap.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->getViewedMap();

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/updateProgress.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->updateProgress();

View File

@@ -0,0 +1,13 @@
<?php
/**
* @OA\Post(
* path="/api/onlyoffice/callback.php",
* summary="ONLYOFFICE save callback",
* tags={"ONLYOFFICE"},
* @OA\Response(response=200, description="OK / error JSON")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->callback();

View File

@@ -0,0 +1,17 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/config.php",
* summary="Get editor config for a file (signed URLs, callback)",
* tags={"ONLYOFFICE"},
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
* @OA\Response(response=200, description="Editor config"),
* @OA\Response(response=403, description="Forbidden"),
* @OA\Response(response=404, description="Disabled / Not found")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->config();

View File

@@ -0,0 +1,15 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/signed-download.php",
* summary="Serve a signed file blob to ONLYOFFICE",
* tags={"ONLYOFFICE"},
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
* @OA\Response(response=200, description="File stream"),
* @OA\Response(response=403, description="Signature/expiry invalid")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->signedDownload();

View File

@@ -0,0 +1,13 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/status.php",
* summary="ONLYOFFICE availability & supported extensions",
* tags={"ONLYOFFICE"},
* @OA\Response(response=200, description="Status JSON")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->status();

View File

@@ -0,0 +1,53 @@
<?php
// public/api/pro/diskUsageChildren.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock to avoid blocking parallel requests
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class available
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
try {
/** @var array $result */
$result = ProDiskUsage::getChildren($folderKey);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,55 @@
<?php
// public/api/pro/diskUsageDeleteFilePermanent.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';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
// Pro-only gate: make sure Pro is really active
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => 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) || empty($body['name'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = isset($body['folder']) ? (string)$body['folder'] : 'root';
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
$name = (string)$body['name'];
$res = FileModel::deleteFilesPermanent($folder, [$name]);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'File deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFilePermanent error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,60 @@
<?php
// public/api/pro/diskUsageDeleteFolderRecursive.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';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => 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) || !isset($body['folder'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = (string)$body['folder'];
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
if (strtolower($folder) === 'root') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Cannot deep delete root folder.']);
return;
}
$res = FolderModel::deleteFolderRecursiveAdmin($folder);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'Folder deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFolderRecursive error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/diskUsageTopFiles.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 100;
try {
$result = ProDiskUsage::getTopFiles($limit);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/groups/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$groups = $ctrl->getProGroups();
echo json_encode([
'success' => true,
'groups' => $groups,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => 'Error loading groups: ' . $e->getMessage(),
]);
}

View File

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

View File

@@ -0,0 +1,27 @@
<?php
// public/api/pro/portals/get.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
try {
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
// For v1: we do NOT require auth here; this is just metadata,
// real ACL/access control must still be enforced at upload/download endpoints.
$portal = PortalController::getPortalBySlug($slug);
echo json_encode([
'success' => true,
'portal' => $portal,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/portals/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$portals = $ctrl->getProPortals();
echo json_encode([
'success' => true,
'portals' => $portals,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<?php
// public/api/pro/portals/uploadLogo.php
declare(strict_types=1);
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->uploadPortalLogo();
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -3,21 +3,29 @@
<head>
<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>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<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">
<!-- 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}}">
<!-- Critical CSS -->
<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="color-scheme" content="light dark">
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
<!-- Critical CSS -->
<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}}">
<!-- Fonts (ok to keep as real preloads) -->
<!-- Fonts -->
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
@@ -27,8 +35,8 @@
<!-- App entry -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head>
</head>
<body>
<div id="appRoot" style="visibility:hidden">
<header class="header-container">
@@ -53,7 +61,27 @@
<h1>FileRise</h1>
</div>
<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 class="header-buttons">
@@ -73,7 +101,7 @@
<!-- Trash items will be loaded here -->
</div>
<div style="text-align: right;">
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
Selected</button>
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
@@ -104,6 +132,7 @@
<!-- Custom Toast Container -->
<div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div>
<div id="appZoomShell">
<main id="main" hidden>
<div class="row mt-4" id="loginForm">
<div class="col-12">
@@ -182,10 +211,6 @@
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<span data-i18n-key="folder_navigation">Folder Navigation &amp; Management</span>
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
style="padding: 0; border: none; background: none;">
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
</button>
</div>
<div class="card-body custom-folder-card-body">
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
@@ -244,6 +269,9 @@
</div>
</div>
</div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i>
@@ -265,17 +293,6 @@
</div>
</div>
</div>
<div id="folderHelpTooltip" class="folder-help-tooltip"
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
subfolder.</li>
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
the appropriate button.</li>
</ul>
</div>
</div>
</div>
</div>
@@ -344,6 +361,10 @@
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="create_folder">Create folder</span>
</li>
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="upload">Upload file(s)</span>
</li>
</ul>
</div>
<!-- Create File Modal -->
@@ -376,7 +397,7 @@
</div> <!-- end container-fluid -->
</div> <!-- end mainColumn -->
</div> <!-- end main-wrapper -->
</div>
<!-- Download Progress Modal -->
<div id="downloadProgressModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;">
@@ -452,6 +473,26 @@
</form>
</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 class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>
@@ -483,7 +524,19 @@
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:900px;width:92vw;">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Upload</h3>
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">&times;</span>
</div>
<div class="modal-body">
<!-- we will MOVE #uploadCard into here while open -->
<div id="uploadModalBody"></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,511 @@
// public/js/adminOnlyOffice.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { showToast } from './domUtils.js?v={{APP_QVER}}';
/**
* Translate with fallback
*/
const tf = (key, fallback) => {
const v = t(key);
return (v && v !== key) ? v : fallback;
};
/**
* Local masked-input renderer (copied from adminPanel.js style)
*/
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
const type = isSecret ? 'password' : 'text';
const disabled = hasValue
? 'disabled data-replace="0" placeholder="•••••• (saved)"'
: 'data-replace="1"';
const replaceBtn = hasValue
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
const note = hasValue
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
: '';
return `
<div class="form-group">
<label for="${id}">${label}:</label>
<div style="display:flex; gap:8px; align-items:center;">
<input type="${type}" id="${id}" class="form-control" ${disabled} />
${replaceBtn}
</div>
${note}
</div>
`;
}
/**
* Local "Replace" wiring (copied from adminPanel.js style, but scoped)
*/
function wireReplaceButtons(scope = document) {
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
if (btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-replace-for');
const inp = scope.querySelector('#' + id);
if (!inp) return;
inp.disabled = false;
inp.dataset.replace = '1';
inp.placeholder = '';
inp.value = '';
btn.textContent = 'Keep saved value';
btn.removeAttribute('data-replace-for');
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
}, { once: true });
});
}
/**
* Trusted origin helper (mirror of your inline logic)
*/
function getTrustedDocsOrigin(raw) {
try {
const u = new URL(String(raw || '').trim());
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
if (u.username || u.password) return null; // no creds in URL
return u.origin;
} catch {
return null;
}
}
function buildOnlyOfficeApiUrl(origin) {
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
u.searchParams.set('probe', String(Date.now()));
return u.toString();
}
/**
* Lightweight JSON helper for this module
*/
async function safeJsonLocal(res) {
const txt = await res.text();
let body = null;
try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg =
(body && (body.error || body.message)) ||
(txt && txt.trim()) ||
`HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body ?? {};
}
/**
* Script probe for api.js (mirrors old ooProbeScript)
*/
async function ooProbeScript(docsOrigin) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const src = buildOnlyOfficeApiUrl(base);
const s = document.createElement('script');
s.id = 'ooProbeScript';
s.async = true;
s.src = src;
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
if (nonce) s.setAttribute('nonce', nonce);
const cleanup = () => { try { s.remove(); } catch { /* ignore */ } };
s.onload = () => { cleanup(); resolve({ ok: true }); };
s.onerror = () => { cleanup(); resolve({ ok: false }); };
// origin is validated, path is fixed => safe
document.head.appendChild(s);
});
}
/**
* Iframe probe for DS (mirrors old ooProbeFrame)
*/
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const f = document.createElement('iframe');
f.id = 'ooProbeFrame';
f.src = base;
f.style.display = 'none';
const cleanup = () => { try { f.remove(); } catch { /* ignore */ } };
const t = setTimeout(() => {
cleanup();
resolve({ ok: false, timeout: true });
}, timeoutMs);
f.onload = () => {
clearTimeout(t);
cleanup();
resolve({ ok: true });
};
f.onerror = () => {
clearTimeout(t);
cleanup();
resolve({ ok: false });
};
// src constrained to validated http/https origin
document.body.appendChild(f);
});
}
/**
* Copy helpers (same behavior you had before)
*/
async function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through
}
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
function selectElementContents(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
/**
* Builds the ONLYOFFICE test card and wires Run tests button
*/
function attachOnlyOfficeTests(container) {
const testBox = document.createElement('div');
testBox.className = 'card';
testBox.style.marginTop = '12px';
testBox.innerHTML = `
<div class="card-body">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<strong>Test ONLYOFFICE connection</strong>
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
<span id="ooTestSpinner" style="display:none;">⏳</span>
</div>
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
<small class="text-muted">
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
</small>
</div>
`;
container.appendChild(testBox);
const spinner = testBox.querySelector('#ooTestSpinner');
const out = testBox.querySelector('#ooTestResults');
function ooRow(label, status, detail = '') {
const li = document.createElement('li');
li.style.margin = '6px 0';
const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌';
li.innerHTML =
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
` <strong>${label}</strong>` +
(detail ? ` — <span>${detail}</span>` : '');
return li;
}
function ooClear() {
while (out.firstChild) out.removeChild(out.firstChild);
}
async function runOnlyOfficeTests() {
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
spinner.style.display = 'inline';
ooClear();
// 1) FileRise status
let statusOk = false;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
const statusJson = await r.json().catch(() => ({}));
if (r.ok) {
if (statusJson.enabled) {
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
statusOk = true;
} else {
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
}
} else {
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
}
} catch (e) {
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
}
// 2) Secret presence (fresh read)
try {
const cfg = await fetch('/api/admin/getConfig.php', {
credentials: 'include',
cache: 'no-store'
}).then(r => r.json());
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
out.appendChild(
ooRow(
'JWT secret saved',
hasSecret ? 'ok' : 'fail',
hasSecret ? 'Present' : 'Missing'
)
);
} catch {
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
}
// 3) Callback reachable
try {
const r = await fetch('/api/onlyoffice/callback.php?ping=1', {
credentials: 'include',
cache: 'no-store'
});
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
} catch {
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
}
// Basic sanity on origin
if (!/^https?:\/\//i.test(docsOrigin)) {
out.appendChild(
ooRow(
'Document Server Origin',
'fail',
'Enter a valid http(s) origin (e.g., https://docs.example.com)'
)
);
spinner.style.display = 'none';
return;
}
// 4a) api.js
const sRes = await ooProbeScript(docsOrigin);
out.appendChild(
ooRow(
'Load api.js',
sRes.ok ? 'ok' : 'fail',
sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'
)
);
// 4b) iframe
const fRes = await ooProbeFrame(docsOrigin);
out.appendChild(
ooRow(
'Embed DS iframe',
fRes.ok ? 'ok' : 'fail',
fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'
)
);
if (!statusOk || !sRes.ok || !fRes.ok) {
const tip = document.createElement('li');
tip.style.marginTop = '8px';
tip.innerHTML =
'💡 <em>Tip:</em> Use the CSP helper below to include your Document Server in ' +
'<code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.';
out.appendChild(tip);
}
spinner.style.display = 'none';
}
testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
}
/**
* CSP helper card (Apache + Nginx snippets)
*/
function attachOnlyOfficeCspHelper(container) {
const cspHelp = document.createElement('div');
cspHelp.className = 'alert alert-info';
cspHelp.style.marginTop = '12px';
cspHelp.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<strong>Content-Security-Policy help</strong>
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
</div>
<div class="form-text" style="margin-bottom:8px;">
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
embedding the editor iframe, and letting the script make XHR to your Document Server.
</div>
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
<div class="form-text" style="margin-top:8px;">
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
otherwise the browser will block it as mixed content.
</div>
<details style="margin-top:8px;">
<summary>Nginx equivalent</summary>
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
</details>
`;
container.appendChild(cspHelp);
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
function buildCspApache(originRaw) {
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
}
function buildCspNginx(originRaw) {
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
}
const ooDocsInput = document.getElementById('ooDocsOrigin');
const cspPre = document.getElementById('ooCspSnippet');
const cspPreNgx = document.getElementById('ooCspSnippetNginx');
function refreshCsp() {
const raw = (ooDocsInput?.value || '').trim();
const base = getTrustedDocsOrigin(raw) || raw;
cspPre.textContent = buildCspApache(base);
cspPreNgx.textContent = buildCspNginx(base);
}
ooDocsInput?.addEventListener('input', refreshCsp);
refreshCsp();
document.getElementById('copyOoCsp')?.addEventListener('click', async () => {
const txt = (cspPre.textContent || '').trim();
const ok = await copyToClipboard(txt);
if (ok) {
showToast('CSP line copied.');
} else {
try { selectElementContents(cspPre); } catch { /* ignore */ }
const reason = window.isSecureContext ? '' : ' (page is not HTTPS or localhost)';
showToast('Copy failed' + reason + '. Press Ctrl/Cmd+C to copy.');
}
});
document.getElementById('selectOoCsp')?.addEventListener('click', () => {
try {
selectElementContents(cspPre);
showToast('Selected — press Ctrl/Cmd+C');
} catch {
/* ignore */
}
});
}
/**
* Public: build + wire ONLYOFFICE admin section
*/
export function initOnlyOfficeUI({ config }) {
const sec = document.getElementById('onlyofficeContent');
if (!sec) return;
const onlyCfg = config.onlyoffice || {};
const hasOOSecret = !!onlyCfg.hasJwtSecret;
window.__HAS_OO_SECRET = hasOOSecret;
// Base content
sec.innerHTML = `
<div class="form-group">
<input type="checkbox" id="ooEnabled" />
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
</div>
<div class="form-group">
<label for="ooDocsOrigin">Document Server Origin:</label>
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
<small class="text-muted">
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
</small>
</div>
${renderMaskedInput({
id: 'ooJwtSecret',
label: 'JWT Secret',
hasValue: hasOOSecret,
isSecret: true
})}
`;
wireReplaceButtons(sec);
// Tests + CSP helper
attachOnlyOfficeTests(sec);
attachOnlyOfficeCspHelper(sec);
// Initial values
const enabled = !!onlyCfg.enabled;
const docsOrigin = onlyCfg.docsOrigin || '';
const enabledEl = document.getElementById('ooEnabled');
const originEl = document.getElementById('ooDocsOrigin');
if (enabledEl) enabledEl.checked = enabled;
if (originEl) originEl.value = docsOrigin;
// Locking (managed in config.php)
const locked = !!onlyCfg.lockedByPhp;
window.__OO_LOCKED = locked;
if (locked) {
sec.querySelectorAll('input,button').forEach(el => {
el.disabled = true;
});
const note = document.createElement('div');
note.className = 'form-text';
note.style.marginTop = '6px';
note.textContent = 'Managed by config.php — edit ONLYOFFICE_* constants there.';
sec.appendChild(note);
}
}
/**
* Public: inject ONLYOFFICE settings into payload (used in handleSave)
*/
export function collectOnlyOfficeSettingsForSave(payload) {
const ooEnabledEl = document.getElementById('ooEnabled');
const ooDocsOriginEl = document.getElementById('ooDocsOrigin');
const ooSecretEl = document.getElementById('ooJwtSecret');
const onlyoffice = {
enabled: !!(ooEnabledEl && ooEnabledEl.checked),
docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || ''
};
if (!window.__OO_LOCKED && ooSecretEl) {
const val = ooSecretEl.value.trim();
const hasSaved = !!window.__HAS_OO_SECRET;
const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
if (shouldReplace && val !== '') {
onlyoffice.jwtSecret = val;
}
}
payload.onlyoffice = onlyoffice;
return payload;
}

File diff suppressed because it is too large Load Diff

1574
public/js/adminPortals.js Normal file

File diff suppressed because it is too large Load Diff

118
public/js/adminSponsor.js Normal file
View File

@@ -0,0 +1,118 @@
// public/js/adminSponsor.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { showToast } from './domUtils.js?v={{APP_QVER}}';
// Tiny "translate with fallback" helper, same as in adminPanel.js
const tf = (key, fallback) => {
const v = t(key);
return (v && v !== key) ? v : fallback;
};
const SPONSOR_GH = 'https://github.com/sponsors/error311';
const SPONSOR_KOFI = 'https://ko-fi.com/error311';
/**
* Initialize the Sponsor / Donations section inside the Admin Panel.
* Safe to call multiple times; it no-ops after the first run.
*/
export function initAdminSponsorSection() {
const container = document.getElementById('sponsorContent');
if (!container) return;
// Avoid double-wiring if initAdminSponsorSection gets called again
if (container.__sponsorInited) return;
container.__sponsorInited = true;
container.innerHTML = `
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorGitHub">${tf("github_sponsors_url", "GitHub Sponsors URL")}:</label>
<div class="input-group">
<input
type="url"
id="sponsorGitHub"
class="form-control"
value="${SPONSOR_GH}"
readonly
data-ignore-dirty="1"
/>
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">
${tf("copy", "Copy")}
</button>
<a
class="btn btn-outline-secondary"
id="openSponsorGitHub"
target="_blank"
rel="noopener"
>
${tf("open", "Open")}
</a>
</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorKoFi">${tf("ko_fi_url", "Ko-fi URL")}:</label>
<div class="input-group">
<input
type="url"
id="sponsorKoFi"
class="form-control"
value="${SPONSOR_KOFI}"
readonly
data-ignore-dirty="1"
/>
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">
${tf("copy", "Copy")}
</button>
<a
class="btn btn-outline-secondary"
id="openSponsorKoFi"
target="_blank"
rel="noopener"
>
${tf("open", "Open")}
</a>
</div>
</div>
<small class="text-muted">
${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
</small>
`;
const ghInput = document.getElementById('sponsorGitHub');
const kfInput = document.getElementById('sponsorKoFi');
const copyGhBtn = document.getElementById('copySponsorGitHub');
const copyKfBtn = document.getElementById('copySponsorKoFi');
const openGh = document.getElementById('openSponsorGitHub');
const openKf = document.getElementById('openSponsorKoFi');
if (openGh) openGh.href = SPONSOR_GH;
if (openKf) openKf.href = SPONSOR_KOFI;
async function copyToClipboardSafe(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast(tf("copied", "Copied!"));
} catch {
showToast(tf("copy_failed", "Could not copy. Please copy manually."));
}
}
if (copyGhBtn && ghInput) {
copyGhBtn.addEventListener('click', () => copyToClipboardSafe(ghInput.value));
}
if (copyKfBtn && kfInput) {
copyKfBtn.addEventListener('click', () => copyToClipboardSafe(kfInput.value));
}
}

1684
public/js/adminStorage.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -34,18 +34,19 @@ window.currentOIDCConfig = currentOIDCConfig;
(function installToastFilter() {
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
const isDemoMode = !!window.__FR_DEMO__;
// Suppress the nag while doing TOTP step-up
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
return null; // suppress
}
// Demo host
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
// Demo mode: swap login prompt for demo creds
if (isDemoMode &&
(msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
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
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.
if (isDemoHost) {
// For the pre-login prompt in demo mode, show demo creds instead
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);
}
// Dont nag during pending TOTP, as you already had
// Dont nag during pending TOTP
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
return;
}
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
let msg = msgKeyOrText;
try {
const translated = t(msgKeyOrText);
// If t() changed it or it's a key-like string, use the translation
if (typeof translated === "string" && translated !== msgKeyOrText) {
msg = translated;
}
} catch { /* if t() isnt available here, just use the original */ }
} catch { }
return originalShowToast(msg);
}
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
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
if (window.location.hostname !== "demo.filerise.net") {
{
let dd = document.getElementById("userDropdown");
// choose icon *or* img
@@ -866,6 +850,10 @@ function initAuth() {
});
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
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("oldPassword").focus();
});
@@ -873,6 +861,10 @@ function initAuth() {
document.getElementById("changePasswordModal").style.display = "none";
});
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 newPassword = document.getElementById("newPassword").value.trim();
const confirmPassword = document.getElementById("confirmPassword").value.trim();

View File

@@ -195,8 +195,7 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px;
max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 500px;
overflow-y: auto; max-height: 600px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box;
scrollbar-width: none;
@@ -352,30 +351,115 @@ export async function openUserPanel() {
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
content.appendChild(dispFs);
// --- Display fieldset: strip + inline folder rows ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
// 1) Show folder strip above list
const stripLabel = document.createElement('label');
stripLabel.style.cursor = 'pointer';
stripLabel.style.display = 'block';
stripLabel.style.marginBottom = '4px';
const stripCb = document.createElement('input');
stripCb.type = 'checkbox';
stripCb.id = 'showFoldersInList';
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', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
// wire up imageinput change
@@ -426,6 +510,18 @@ export async function openUserPanel() {
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
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

View File

@@ -156,15 +156,15 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
export function buildFileTableHeader(sortOrder) {
return `
<table class="table">
<table class="table filr-table table-hover table-striped">
<thead>
<tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_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="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${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="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (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("modified")} ${sortOrder.column === "modified" ? (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("size")} ${sortOrder.column === "size" ? (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>
</tr>
</thead>
@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
const safeUploader = escapeHTML(file.uploader || "Unknown");
let previewButton = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
const isSvg = /\.svg$/i.test(file.name);
// IMPORTANT: do NOT treat SVG as previewable
if (
!isSvg &&
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
.test(file.name)
) {
let previewIcon = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
// images (SVG explicitly excluded)
if (
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
.test(file.name)
) {
previewIcon = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`;
@@ -190,14 +203,16 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`;
}
previewButton = `<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
}
return `
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
<i class="material-icons">drive_file_rename_outline</i>
</button>
<!-- share -->
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div>
</td>
</tr>
@@ -283,9 +298,9 @@ export function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (!row) return;
if (checkbox.checked) {
row.classList.add('row-selected');
row.classList.add('row-selected', 'selected');
} else {
row.classList.remove('row-selected');
row.classList.remove('row-selected', 'selected');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) {
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
showToast("no_files_selected");
return;
}
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
const count = window.filesToDelete.length;
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
@@ -20,6 +20,73 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
}
const FILE_MODAL_IDS = [
'deleteFilesModal',
'downloadZipModal',
'downloadProgressModal',
'createFileModal',
'downloadFileModal',
'copyFilesModal',
'moveFilesModal',
'renameFileModal',
'createFolderModal', // if this exists in your HTML
];
function portalFileModalsToBody() {
FILE_MODAL_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el.parentNode !== document.body) {
document.body.appendChild(el);
}
});
}
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
export function openUploadModal() {
const modal = document.getElementById('uploadModal');
const body = document.getElementById('uploadModalBody');
const card = document.getElementById('uploadCard'); // <-- your existing card
window.openUploadModal = openUploadModal;
window.__pendingDropData = null;
if (!modal || !body || !card) {
console.warn('Upload modal or upload card not found');
return;
}
// Create a hidden sentinel so we can put the card back in place later
if (!_uploadCardSentinel) {
_uploadCardSentinel = document.createElement('div');
_uploadCardSentinel.id = 'uploadCardSentinel';
_uploadCardSentinel.style.display = 'none';
card.parentNode.insertBefore(_uploadCardSentinel, card);
}
// Move the actual card node into the modal (keeps all existing listeners)
body.appendChild(card);
// Show modal
modal.style.display = 'block';
// Focus the chooser for quick keyboard flow
setTimeout(() => {
const chooseBtn = document.getElementById('customChooseBtn');
if (chooseBtn) chooseBtn.focus();
}, 50);
}
export function closeUploadModal() {
const modal = document.getElementById('uploadModal');
const card = document.getElementById('uploadCard');
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
}
if (modal) modal.style.display = 'none';
}
document.addEventListener("DOMContentLoaded", function () {
const cancelDelete = document.getElementById("cancelDeleteFiles");
if (cancelDelete) {
@@ -47,6 +114,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not delete files"));
}
@@ -119,7 +187,7 @@ export async function handleCreateFile(e) {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type':'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
// ⚠️ must send `name`, not `filename`
@@ -129,6 +197,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
@@ -139,7 +208,7 @@ export async function handleCreateFile(e) {
document.addEventListener('DOMContentLoaded', () => {
const cancel = document.getElementById('cancelCreateFile');
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);
});
@@ -265,7 +334,7 @@ document.addEventListener("DOMContentLoaded", () => {
const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate) {
cancelCreate.addEventListener('click', () => {
document.getElementById('createFileModal').style.display = 'none';
@@ -300,12 +369,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
}
});
attachEnterKeyListener('createFileModal','confirmCreateFile');
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
}
// 1) Cancel button hides the name modal
@@ -321,63 +391,187 @@ document.addEventListener("DOMContentLoaded", () => {
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) {
showToast("Please enter a name for the zip file.");
return;
}
if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip";
}
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
// b) Hide the nameinput modal, show the spinner modal
// b) Hide the nameinput modal, show the progress modal
zipNameModal.style.display = "none";
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");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
try {
// d) POST and await the ZIP blob
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || "root",
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 browsers 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";
// d) Queue the job
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
});
const jsr = await res.json().catch(() => ({}));
if (!res.ok || !jsr.ok) {
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
throw new Error(msg);
}
const token = jsr.token;
const statusUrl = jsr.statusUrl;
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
// Ensure a progress UI exists in the modal
function ensureZipProgressUI() {
const modalEl = document.getElementById("downloadProgressModal");
if (!modalEl) {
// really shouldn't happen, but fall back to body
console.warn("downloadProgressModal not found; falling back to document.body");
}
// Prefer a dedicated content node inside the modal
let host =
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
(modalEl && modalEl.querySelector(".modal-body")) ||
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
(modalEl && modalEl.querySelector(".modal-content")) ||
(modalEl && modalEl.querySelector(".content")) ||
null;
// If no suitable container, create one inside the modal
if (!host) {
host = document.createElement("div");
host.id = "downloadProgressContent";
(modalEl || document.body).appendChild(host);
}
// 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 098% while enumerating
const pct = Math.max(0, Math.min(98, s.pct | 0));
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
ui.bar.value = pct;
ui.text.textContent =
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
} else {
// FINALIZING: keep progress at 100% and show timer + selected totals
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
ui.bar.value = 100; // lock at 100 during finalizing
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
ui.text.textContent = `Finalizing… ${mmss(since)}${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
}
} else if (ui.text) {
ui.text.textContent = "Still preparing…";
}
// --- /RENDER ---
if (s.ready) {
// Snap to 100 and close modal just before download
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
progressModal.style.display = "none";
await new Promise(r => setTimeout(r, 0));
break;
}
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
}
// f) Trigger download
const a = document.createElement("a");
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 +703,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else {
showToast("Error: " + (data.error || "Could not copy files"), 5000);
}
@@ -561,6 +756,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files moved successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not move files"));
}
@@ -642,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Expose initFileActions so it can be called from fileManager.js
export function initFileActions() {
portalFileModalsToBody();
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
if (deleteSelectedBtn) {
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
@@ -694,10 +892,11 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption');
const folderOpt= document.getElementById('createFolderOption');
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption');
const folderOpt = document.getElementById('createFolderOption');
const uploadOpt = document.getElementById('uploadOption'); // NEW
// Toggle dropdown on click
btn.addEventListener('click', (e) => {
@@ -722,6 +921,32 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', () => {
menu.style.display = 'none';
});
if (uploadOpt) {
uploadOpt.addEventListener('click', () => {
if (menu) menu.style.display = 'none';
openUploadModal();
});
}
// Close buttons / backdrop
const upModal = document.getElementById('uploadModal');
const closeX = document.getElementById('closeUploadModal');
if (closeX) closeX.addEventListener('click', closeUploadModal);
// click outside content to close
if (upModal) {
upModal.addEventListener('click', (e) => {
if (e.target === upModal) closeUploadModal();
});
}
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
closeUploadModal();
}
});
});
window.renameFile = renameFile;

View File

@@ -2,124 +2,163 @@
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
export function fileDragStartHandler(event) {
const row = event.currentTarget;
let fileNames = [];
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
if (selectedCheckboxes.length > 1) {
selectedCheckboxes.forEach(chk => {
const parentRow = chk.closest("tr");
if (parentRow) {
const cell = parentRow.querySelector("td:nth-child(2)");
if (cell) {
let rawName = cell.textContent.trim();
const tagContainer = cell.querySelector(".tag-badges");
if (tagContainer) {
const tagText = tagContainer.innerText.trim();
if (rawName.endsWith(tagText)) {
rawName = rawName.slice(0, -tagText.length).trim();
}
}
fileNames.push(rawName);
}
}
});
} else {
const fileNameCell = row.querySelector("td:nth-child(2)");
if (fileNameCell) {
let rawName = fileNameCell.textContent.trim();
const tagContainer = fileNameCell.querySelector(".tag-badges");
if (tagContainer) {
const tagText = tagContainer.innerText.trim();
if (rawName.endsWith(tagText)) {
rawName = rawName.slice(0, -tagText.length).trim();
}
}
fileNames.push(rawName);
}
}
if (fileNames.length === 0) return;
const dragData = fileNames.length === 1
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
let dragImage = document.createElement("div");
dragImage.style.display = "inline-flex";
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);
/* ---------------- helpers ---------------- */
function getRowEl(el) {
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
}
function getNameFromAny(el) {
const row = getRowEl(el);
if (!row) return null;
// 1) canonical
const n = row.getAttribute('data-file-name');
if (n) return n;
// 2) filename-only span
const span = row.querySelector('.filename-text');
if (span) return span.textContent.trim();
return null;
}
function getSelectedFileNames() {
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
// de-dup just in case
return Array.from(new Set(names));
}
function makeDragImage(labelText, iconName = 'insert_drive_file') {
const wrap = document.createElement('div');
Object.assign(wrap.style, {
display: 'inline-flex',
maxWidth: '420px',
padding: '6px 10px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '6px',
alignItems: 'center',
gap: '6px',
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
fontSize: '12px',
pointerEvents: 'none'
});
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = iconName;
const label = document.createElement('span');
// trim long single-name labels
const txt = String(labelText || '');
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
wrap.appendChild(icon);
wrap.appendChild(label);
document.body.appendChild(wrap);
return wrap;
}
/* ---------------- 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 rows 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) {
event.preventDefault();
event.currentTarget.classList.add("drop-hover");
event.currentTarget.classList.add('drop-hover');
}
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.currentTarget.classList.remove("drop-hover");
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData;
event.currentTarget.classList.remove('drop-hover');
const dropFolder = event.currentTarget.getAttribute('data-folder')
|| event.currentTarget.getAttribute('data-dest-folder')
|| 'root';
// parse drag payload
let dragData = null;
try {
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
} catch (e) {
console.error("Invalid drag data");
const raw = event.dataTransfer.getData('application/json') || '{}';
dragData = JSON.parse(raw);
} catch {
// ignore
}
if (!dragData) {
showToast('Invalid drag data.');
return;
}
if (!dragData || !dragData.fileName) return;
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
},
body: JSON.stringify({
source: dragData.sourceFolder,
files: [dragData.fileName],
destination: dropFolder
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder);
} else {
showToast("Error moving file: " + (data.error || "Unknown error"));
}
})
.catch(error => {
console.error("Error moving file via drop:", error);
showToast("Error moving file.");
// normalize names
let names = Array.isArray(dragData.files) ? dragData.files.slice()
: dragData.fileName ? [dragData.fileName]
: [];
names = names.filter(v => typeof v === 'string' && v.length > 0);
if (names.length === 0) {
showToast('No files to move.');
return;
}
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
if (dropFolder === sourceFolder) {
showToast('Source and destination are the same.');
return;
}
// POST move
try {
const res = await fetch('/api/file/moveFiles.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'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).');
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,246 @@
// fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
import { t } from './i18n.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 { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.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) {
let menu = document.getElementById("fileContextMenu");
if (!menu) {
menu = document.createElement("div");
menu.id = "fileContextMenu";
menu.style.position = "fixed";
menu.style.backgroundColor = "#fff";
menu.style.border = "1px solid #ccc";
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.padding = "5px 0";
menu.style.minWidth = "150px";
document.body.appendChild(menu);
}
menu.innerHTML = "";
menuItems.forEach(item => {
let menuItem = document.createElement("div");
menuItem.textContent = item.label;
menuItem.style.padding = "5px 15px";
menuItem.style.cursor = "pointer";
menuItem.addEventListener("mouseover", () => {
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
});
menuItem.addEventListener("mouseout", () => {
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
const MENU_ID = 'fileContextMenu';
function qMenu() { return document.getElementById(MENU_ID); }
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
// One-time: localize labels
function localizeMenu() {
const m = qMenu(); if (!m) return;
const map = {
'create_file': 'create_file',
'delete_selected': 'delete_selected',
'copy_selected': 'copy_selected',
'move_selected': 'move_selected',
'download_zip': 'download_zip',
'extract_zip': 'extract_zip',
'tag_selected': 'tag_selected',
'preview': 'preview',
'edit': 'edit',
'rename': 'rename',
'tag_file': 'tag_file'
};
Object.entries(map).forEach(([action, key]) => {
const el = m.querySelector(`.mi[data-action="${action}"]`);
if (el) setText(el, key);
});
}
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
// Show/hide items based on selection state
function configureVisibility({ any, one, many, anyZip, canEdit }) {
const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) {
let newTop = viewportHeight - menuRect.height;
if (newTop < 0) newTop = 0;
menu.style.top = newTop + "px";
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
show(m.querySelectorAll('[data-when="always"]'), true);
show(m.querySelectorAll('[data-when="any"]'), any);
show(m.querySelectorAll('[data-when="one"]'), one);
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() {
const menu = document.getElementById("fileContextMenu");
if (menu) {
menu.style.display = "none";
}
const m = qMenu();
if (m) m.hidden = true;
}
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) {
e.preventDefault();
let row = e.target.closest("tr");
// Check row if needed
const row = e.target.closest('tr');
if (row) {
const checkbox = row.querySelector(".file-checkbox");
if (checkbox && !checkbox.checked) {
checkbox.checked = true;
updateRowHighlight(checkbox);
const cb = row.querySelector('.file-checkbox');
if (cb && !cb.checked) {
cb.checked = true;
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 = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ 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);
// Stash for click handlers
window.__filr_ctx_state = state;
}
// --- 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 cant 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() {
const fileListContainer = document.getElementById("fileList");
if (fileListContainer) {
fileListContainer.oncontextmenu = fileListContextMenuHandler;
const container = document.getElementById('fileList');
const menu = qMenu();
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) {
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
}
});
// Rebind context menu after file table render.
// Rebind after table render (keeps your original behavior)
(function () {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function (folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};
const orig = window.renderFileTable;
if (typeof orig === 'function') {
window.renderFileTable = function (folder) {
orig(folder);
bindFileListContextMenu();
};
} else {
// If not present yet, bind once DOM is ready
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
}
})();

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,8 @@ const translations = {
"tag_name": "Tag Name:",
"tag_color": "Tag Color:",
"save_tag": "Save Tag",
"no_tags_available": "No tags available",
"current_tags": "Current Tags",
"light_mode": "Light Mode",
"dark_mode": "Dark Mode",
"upload_instruction": "Drop files/folders here or click 'Choose files'",
@@ -233,7 +235,7 @@ const translations = {
"error_generating_recovery_code": "Error generating recovery code",
"error_loading_qr_code": "Error loading QR code.",
"error_disabling_totp_setting": "Error disabling TOTP setting",
"user_management": "User Management",
"user_management": "Users, Groups & Access",
"add_user": "Add User",
"remove_user": "Remove User",
"user_permissions": "User Permissions",
@@ -268,7 +270,7 @@ const translations = {
"columns": "Columns",
"row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"show_folders_above_files": "Show folder strip above list",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
@@ -302,7 +304,46 @@ const translations = {
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
"context_move_folder": "Move Folder...",
"context_move_here": "Move Here",
"context_move_cancel": "Cancel Move"
"context_move_cancel": "Cancel Move",
"mark_as_viewed": "Mark as viewed",
"viewed": "Viewed",
"resumed_from": "Resumed from",
"clear_progress": "Clear progress",
"marked_viewed": "Marked as viewed",
"progress_cleared": "Progress cleared",
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders",
"show_inline_folders": "Show folders as rows above files",
"name": "Name",
"size": "Size",
"modified": "Modified",
"created": "Created",
"owner": "Owner",
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -62,23 +62,43 @@ async function ensureToastReady() {
}
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';
}
function showLoginTip(message) {
const tip = document.getElementById('fr-login-tip');
if (!tip) return;
tip.innerHTML = ''; // clear
if (message) tip.append(document.createTextNode(message));
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
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.innerHTML = ''; // clear
if (message) {
tip.append(document.createTextNode(message));
}
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.style.display = 'block'; // reveal without shifting layout
tip.style.display = 'block';
}
async function hideOverlaySmoothly(overlay) {
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
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))
const origToast = window.showToast;
if (typeof origToast === 'function' && !origToast.__frWrapped) {
@@ -403,39 +449,91 @@ function bindDarkMode() {
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
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) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
row.removeAttribute('hidden');
row.style.display = '';
loginWrap.setAttribute('hidden', '');
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"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
@@ -474,11 +572,13 @@ function bindDarkMode() {
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
window.__FR_SITE_CFG__ = j || {};
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
// Early pass: title + login options (skip touching <h1> to avoid flicker)
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
return window.__FR_SITE_CFG__;
} catch {
window.__FR_SITE_CFG__ = {};
window.__FR_DEMO__ = false;
applySiteConfig({}, { phase: 'early' });
return null;
}
@@ -831,6 +931,19 @@ function bindDarkMode() {
});
}
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();
(function poll() {
checkAuth().then(({ authed }) => {
@@ -1037,6 +1150,21 @@ function bindDarkMode() {
if (login) login.style.display = '';
// …wire stuff…
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();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
@@ -1057,4 +1185,52 @@ function bindDarkMode() {
if (overlay) overlay.style.display = 'none';
}, { once: true });
})();
// --- Mobile switcher + PWA SW (mobile-only) ---
(() => {
// keep it simple + robust
const qs = new URLSearchParams(location.search);
const hasFrAppHint = qs.get('frapp') === '1';
const isStandalone =
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
(typeof navigator.standalone === 'boolean' && navigator.standalone);
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
const isMobileish =
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
const shouldLoadSwitcher =
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
// expose a flag to inspect later
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
if (shouldLoadSwitcher) {
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
.then(() => {
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
sessionStorage.setItem('frx_opened_once', '1');
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
}
})
.catch(err => console.info('[FileRise] switcher import failed:', err));
}
// SW only for web (https or localhost), never in Capacitor
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
});
}
})();

View File

@@ -0,0 +1,365 @@
(function(){
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
if (!isCap) return;
// NOTE: allow running inside Capacitor (origin "capacitor://localhost")
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
const Pref = Plugins.Preferences ? {
get: ({key}) => Plugins.Preferences.get({key}),
set: ({key,value}) => Plugins.Preferences.set({key,value}),
remove:({key}) => Plugins.Preferences.remove({key})
} : {
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
set: async ({key,value}) => localStorage.setItem(key, value),
remove: async ({key}) => localStorage.removeItem(key)
};
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
const $ = s => document.querySelector(s);
// Safe element builder: attributes only, children as nodes/strings (no innerHTML)
const el = (tag, attrs = {}, children = []) => {
const n = document.createElement(tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
(Array.isArray(children) ? children : [children]).forEach(c => {
if (c == null) return;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return n;
};
// Normalize to http(s), strip creds, collapse trailing slashes
const normalize = (u) => {
if (!u) return '';
let v = u.trim();
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
try {
const url = new URL(v);
if (!/^https?:$/.test(url.protocol)) return '';
url.username = '';
url.password = '';
url.pathname = url.pathname.replace(/\/+$/,'');
return url.toString();
} catch { return ''; }
};
// Append/overwrite a query param safely on a normalized URL
const withParam = (base, k, v) => {
try {
const u = new URL(normalize(base));
u.searchParams.set(k, v);
return u.toString();
} catch { return ''; }
};
const host = u => {
try { return new URL(normalize(u)).hostname; } catch { return ''; }
};
const originOf = u => {
try { return new URL(normalize(u)).origin; } catch { return ''; }
};
const faviconUrl = u => {
try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; }
};
const initialsIcon = (hn='FR') => {
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
};
async function getStatusCache(){
const raw=(await Pref.get({key:K_STATUS})).value;
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
}
async function writeStatus(origin, ok){
const cache=await getStatusCache();
cache[origin]={ ok, ts: Date.now() };
await Pref.set({key:K_STATUS, value:JSON.stringify(cache)});
}
async function verifyFileRise(u, timeout=5000){
if (!u || !Http) return {ok:false};
const base = normalize(u), org = originOf(base);
const tryJson = async (url, validate) => {
try{
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
if (r && r.data) {
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
return !!validate(j);
}
}catch(_){}
return false;
};
if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org};
if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org};
if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org};
try{
const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org};
}catch(_){}
return {ok:false, origin:org};
}
async function probeReachable(u, timeout=3000){
try{
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
if (Http){
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
return false;
}
return await new Promise(res=>{
const img=new Image(), t=setTimeout(()=>done(false), timeout);
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
img.onload=()=>done(true); img.onerror=()=>done(false);
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
});
}catch{ return false; }
}
async function loadInstances(){
const raw=(await Pref.get({key:K_INST})).value;
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
}
async function saveInstances(list){
await Pref.set({key:K_INST, value:JSON.stringify(list)});
}
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
if (!$('#frx-mobile-style')) {
const css = `
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
.frx-fab:active { transform: translateY(1px) scale(.98); }
.frx-fab svg { width:26px; height:26px; fill:white }
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
.frx-scrim.show{opacity:1;visibility:visible}
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
.frx-title img{width:22px;height:22px}
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
.frx-left{display:flex;gap:10px;align-items:center}
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
.frx-name{font-weight:800}
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
.frx-dot{width:10px;height:10px;border-radius:50%;}
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
.frx-row{display:flex;gap:8px;align-items:center}
.frx-field{display:grid;gap:6px;margin:8px 4px}
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
`;
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
}
// ---- DOM skeleton (no innerHTML) ----
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'});
const hdr = el('div',{class:'hdr'});
const title = el('div',{class:'frx-title'});
const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'});
// inline handler via property, not attribute
logo.onerror = function(){ this.style.display='none'; };
title.append(logo, el('span',{},'FileRise Switcher'));
const hdrBtns = el('div',{class:'frx-row'},[
el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'),
el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close')
]);
hdr.append(title, hdrBtns);
const list = el('div',{class:'frx-list', id:'frx-list'});
const formWrap = el('div',{style:'padding:10px 12px'},[
el('div',{class:'frx-field'},[
el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}),
el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'})
])
]);
const footer = el('div',{class:'frx-footer'},[
el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'),
el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server')
]);
sheet.append(hdr, list, formWrap, footer);
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[
el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ])
]);
document.body.appendChild(scrim);
document.body.appendChild(sheet);
document.body.appendChild(fab);
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
$('#frx-close').addEventListener('click', hide);
$('#frx-add-cancel').addEventListener('click', hide);
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
scrim.addEventListener('click', hide);
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
function chipNode(item, isActive){
const hv = host(item.url);
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
const top = el('div',{class:'frx-top'});
const left = el('div',{class:'frx-left'});
const ico = el('div',{class:'frx-ico'});
const img = new Image();
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
ico.appendChild(img);
const txt = el('div',{},[
el('div',{class:'frx-name'}, (item.name || hv)),
el('div',{class:'frx-host'}, hv)
]);
left.appendChild(ico);
left.appendChild(txt);
const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`});
const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…');
const status = el('div',{class:'frx-status'}, [dot, lbl]);
top.appendChild(left);
top.appendChild(status);
const actions = el('div',{class:'frx-actions'});
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
node.appendChild(top);
node.appendChild(actions);
return node;
}
async function renderList(){
const listEl=$('#frx-list'); listEl.textContent='';
const list=await loadInstances(); const active=await getActive();
const cache=await getStatusCache();
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
const chip = chipNode(item, item.id===active);
const o = originOf(item.url), cached = cache[o];
const dot = chip.querySelector(`#frx-dot-${item.id}`);
const lbl = chip.querySelector(`#frx-lbl-${item.id}`);
const openBtn = chip.querySelector('[data-act="open"]');
if (cached){
dot.classList.add(cached.ok ? 'on':'off');
lbl.textContent = cached.ok ? 'Online' : 'Offline';
openBtn.disabled = !cached.ok;
} else {
lbl.textContent = 'Unknown';
openBtn.disabled = true;
}
chip.addEventListener('click', async (e)=>{
const act = e.target?.dataset?.act;
if (!act) return;
if (act==='open'){
if (openBtn.disabled) return;
await setActive(item.id);
const dest = withParam(item.url, 'frapp', '1');
if (dest) window.location.replace(dest);
} else if (act==='rename'){
const nn=prompt('New display name:', item.name || host(item.url));
if (nn!=null){
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
}
} else if (act==='remove'){
if (!confirm('Remove this server?')) return;
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
}
});
listEl.appendChild(chip);
// Live refresh (best effort)
(async ()=>{
const ok = await probeReachable(item.url, 2500);
const d = document.getElementById(`frx-dot-${item.id}`);
const l = document.getElementById(`frx-lbl-${item.id}`);
const b = chip.querySelector('[data-act="open"]');
if (d && l && b){
d.classList.remove('on','off');
d.classList.add(ok?'on':'off');
l.textContent = ok ? 'Online' : 'Offline';
b.disabled = !ok;
}
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
})();
});
}
$('#frx-add-save').addEventListener('click', async ()=>{
const name = $('#frx-name').value.trim();
const url = $('#frx-url').value.trim();
if (!url) { alert('Enter a valid URL'); return; }
// Verify: must be FileRise
const vf = await verifyFileRise(url);
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
let L = await loadInstances();
const h = host(url);
const dupe = L.find(i => host(i.url)===h);
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
inst.name = name || inst.name || h;
inst.url = normalize(url);
inst.favicon = faviconUrl(url);
inst.lastUsed = Date.now();
if (!dupe) L.push(inst);
await saveInstances(L);
await setActive(inst.id);
if (vf.origin) await writeStatus(vf.origin, true);
const dest = withParam(inst.url, 'frapp', '1');
if (dest) window.location.replace(dest);
});
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
// Ensure zoom gestures work if the host page tried to disable them
(function ensureZoomable(){
let m = document.querySelector('meta[name=viewport]');
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
const c = m.getAttribute('content') || '';
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
})();
})();

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

@@ -0,0 +1,401 @@
// 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');
const logoEl = document.getElementById('portalLoginLogo');
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';
}
}
// 🔹 Portal logo: use logoFile from metadata if present
if (logoEl) {
let logoSrc = null;
// If you ever decide to store a direct URL:
if (portal.logoUrl && portal.logoUrl.trim()) {
logoSrc = portal.logoUrl.trim();
} else if (portal.logoFile && portal.logoFile.trim()) {
// Same convention as portal.html: files live in uploads/profile_pics
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
}
if (logoSrc) {
logoEl.src = logoSrc;
logoEl.alt = title;
}
}
// 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';
}
});
});

1034
public/js/portal.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
});
}

9
public/js/pwa/sw.js Normal file
View File

@@ -0,0 +1,9 @@
// public/js/pwa/sw.js
const SW_VERSION = '{{APP_QVER}}';
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
const STATIC_ASSETS = [
'/', '/index.html',
'/css/styles.css?v={{APP_QVER}}',
'/js/main.js?v={{APP_QVER}}',
'/assets/logo.svg?v={{APP_QVER}}'
];

View File

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

View File

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

View File

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

92
public/js/zoom.js Normal file
View 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();
}
})();

View File

@@ -0,0 +1,14 @@
{
"name": "FileRise",
"short_name": "FileRise",
"start_url": "/?pwa=1",
"scope": "/",
"display": "standalone",
"background_color": "#111111",
"theme_color": "#0b5ed7",
"icons": [
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

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

@@ -0,0 +1,148 @@
<!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 id="portalLoginLogo"
src="/assets/logo.svg?v={{APP_QVER}}"
alt="FileRise">
<div>
<div id="portalLoginTitle" class="portal-login-title">
Sign in to Client Portal
</div>
<div id="portalLoginSubtitle" class="portal-login-subtitle">
to access this client portal
</div>
</div>
</div>
<div id="portalLoginError" class="alert alert-danger"></div>
<form id="portalLoginForm" novalidate>
<div class="form-group">
<label for="portalLoginUser">Username or email</label>
<input type="text"
class="form-control form-control-sm"
id="portalLoginUser"
autocomplete="username"
required>
</div>
<div class="form-group">
<label for="portalLoginPass">Password</label>
<input type="password"
class="form-control form-control-sm"
id="portalLoginPass"
autocomplete="current-password"
required>
</div>
<button type="submit"
id="portalLoginSubmit"
class="btn btn-primary btn-sm btn-block">
Sign in
</button>
</form>
<small id="portalLoginHint"
class="text-muted d-block mt-2"
style="font-size:0.75rem;">
Youll be sent back to the portal automatically after signing in.
</small>
<small id="portalLoginFooter"
class="text-muted d-block mt-1"
style="font-size:0.7rem; display:none;">
</small>
</div>
</div>
</body>
</html>

376
public/portal.html Normal file
View File

@@ -0,0 +1,376 @@
<!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;
}
.portal-required-star {
color: #dc3545;
}
</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 id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelName" for="portalFormName">Name</label>
<input type="text" id="portalFormName" class="form-control form-control-sm">
</div>
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
</div>
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
<input type="text" id="portalFormReference" class="form-control form-control-sm">
</div>
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
<label id="portalFormLabelNotes" 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="portalThankYouSection"
style="margin-top:12px; display:none;">
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
<strong>Thank you!</strong>
<span id="portalThankYouMessage">
Your files have been uploaded.
</span>
</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>

6
public/sw.js Normal file
View File

@@ -0,0 +1,6 @@
// Root-scoped stub. Keeps the workers scope at “/” level
try {
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
} catch (_) {
// no-op
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 581 KiB

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