Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e37738e3f | ||
|
|
2ba33f40f8 | ||
|
|
badcf5c02b | ||
|
|
89976f444f | ||
|
|
9c53c37f38 | ||
|
|
a400163dfb | ||
|
|
ebe5939bf5 | ||
|
|
83757c7470 | ||
|
|
8e363ea758 | ||
|
|
2739925f0b | ||
|
|
b5610cf156 | ||
|
|
ae932a9aa9 | ||
|
|
a106d47f77 | ||
|
|
41d464a4b3 | ||
|
|
9e69f19e23 | ||
|
|
1df7bc3f87 | ||
|
|
e5f9831d73 | ||
|
|
553bc84404 | ||
|
|
88a8857a6f | ||
|
|
edefaaca36 | ||
|
|
ef0a8da696 | ||
|
|
ebabb561d6 | ||
|
|
30761b6dad | ||
|
|
9ef40da5aa | ||
|
|
371a763fb4 | ||
|
|
ee717af750 | ||
|
|
0ad7034a7d | ||
|
|
d29900d6ba | ||
|
|
5ffc068041 | ||
|
|
1935cb2442 | ||
|
|
af9887e651 | ||
|
|
327eea2835 | ||
|
|
3843daa228 | ||
|
|
169e03be5d | ||
|
|
be605b4522 | ||
|
|
090286164d | ||
|
|
dc1649ace3 | ||
|
|
b6d86b7896 | ||
|
|
25ce6a76be | ||
|
|
f2ab2a96bc | ||
|
|
c22c8e0f34 | ||
|
|
070515e7a6 | ||
|
|
7a0f4ddbb4 | ||
|
|
e1c15eb95a | ||
|
|
2400dcb9eb | ||
|
|
c717f8be60 | ||
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 | ||
|
|
16ccb66d55 | ||
|
|
9209f7a582 | ||
|
|
4a736b0224 | ||
|
|
f162a7d0d7 | ||
|
|
3fc526df7f | ||
|
|
20422cf5a7 | ||
|
|
492bab36ca | ||
|
|
f2f7697994 | ||
|
|
13aa011632 | ||
|
|
1add160f5d | ||
|
|
87368143b5 | ||
|
|
939aa032f0 | ||
|
|
fbd21a035b | ||
|
|
2f391d11db | ||
|
|
8c70783d5a | ||
|
|
b4d6f01432 | ||
|
|
d48b15a5f4 |
@@ -12,3 +12,9 @@ tmp/
|
|||||||
.env
|
.env
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
users/
|
||||||
|
metadata/
|
||||||
|
sessions/
|
||||||
|
vendor/
|
||||||
|
|||||||
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
github: [error311]
|
||||||
|
ko_fi: error311
|
||||||
92
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: CI
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.1', '8.2', '8.3']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
coverage: none
|
||||||
|
- name: Validate composer.json (if present)
|
||||||
|
run: |
|
||||||
|
if [ -f composer.json ]; then composer validate --no-check-publish; fi
|
||||||
|
- name: Composer audit (if lock present)
|
||||||
|
run: |
|
||||||
|
if [ -f composer.lock ]; then composer audit || true; fi
|
||||||
|
- name: PHP syntax check
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t files < <(git ls-files '*.php')
|
||||||
|
if [ "${#files[@]}" -gt 0 ]; then
|
||||||
|
for f in "${files[@]}"; do php -l "$f"; done
|
||||||
|
else
|
||||||
|
echo "No PHP files found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
shellcheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
|
- name: ShellCheck all scripts
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t sh < <(git ls-files '*.sh')
|
||||||
|
if [ "${#sh[@]}" -gt 0 ]; then
|
||||||
|
shellcheck "${sh[@]}"
|
||||||
|
else
|
||||||
|
echo "No shell scripts found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
dockerfile-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Lint Dockerfile with hadolint
|
||||||
|
uses: hadolint/hadolint-action@v3.1.0
|
||||||
|
with:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
failure-threshold: error
|
||||||
|
ignore: DL3008,DL3059
|
||||||
|
|
||||||
|
sanity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y jq yamllint
|
||||||
|
- name: Lint JSON
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t jsons < <(git ls-files '*.json' ':!:vendor/**')
|
||||||
|
if [ "${#jsons[@]}" -gt 0 ]; then
|
||||||
|
for j in "${jsons[@]}"; do jq -e . "$j" >/dev/null; done
|
||||||
|
else
|
||||||
|
echo "No JSON files."
|
||||||
|
fi
|
||||||
|
- name: Lint YAML
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t yamls < <(git ls-files '*.yml' '*.yaml')
|
||||||
|
if [ "${#yamls[@]}" -gt 0 ]; then
|
||||||
|
yamllint -d "{extends: default, rules: {line-length: disable, truthy: {check-keys: false}}}" "${yamls[@]}"
|
||||||
|
else
|
||||||
|
echo "No YAML files."
|
||||||
|
fi
|
||||||
107
.github/workflows/release-on-version.yml
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
name: Release on version.js update
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- public/js/version.js
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}-${{ github.sha }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Read version from version.js
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Parsed version: $VER"
|
||||||
|
|
||||||
|
- name: Skip if tag already exists
|
||||||
|
id: tagcheck
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git fetch --tags --quiet
|
||||||
|
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Prepare release notes from CHANGELOG.md (optional)
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
id: notes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
NOTES_PATH=""
|
||||||
|
if [[ -f CHANGELOG.md ]]; then
|
||||||
|
awk '
|
||||||
|
BEGIN{found=0}
|
||||||
|
/^## / && !found {found=1}
|
||||||
|
found && /^---$/ {exit}
|
||||||
|
found {print}
|
||||||
|
' CHANGELOG.md > RELEASE_BODY.md || true
|
||||||
|
|
||||||
|
# Trim trailing blank lines
|
||||||
|
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' RELEASE_BODY.md || true
|
||||||
|
|
||||||
|
if [[ -s RELEASE_BODY.md ]]; then
|
||||||
|
NOTES_PATH="RELEASE_BODY.md"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: (optional) Build archive to attach
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
zip -r "FileRise-${{ steps.ver.outputs.version }}.zip" public/ README.md LICENSE >/dev/null || true
|
||||||
|
|
||||||
|
# Path A: we have extracted notes -> use body_path
|
||||||
|
- name: Create GitHub Release (with CHANGELOG snippet)
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path != ''
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.ver.outputs.version }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: ${{ steps.ver.outputs.version }}
|
||||||
|
body_path: ${{ steps.notes.outputs.path }}
|
||||||
|
generate_release_notes: false
|
||||||
|
files: |
|
||||||
|
FileRise-${{ steps.ver.outputs.version }}.zip
|
||||||
|
|
||||||
|
# Path B: no notes -> let GitHub auto-generate from commits
|
||||||
|
- name: Create GitHub Release (auto notes)
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path == ''
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.ver.outputs.version }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: ${{ steps.ver.outputs.version }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
FileRise-${{ steps.ver.outputs.version }}.zip
|
||||||
59
.github/workflows/sync-changelog.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: Sync Changelog to Docker Repo
|
---
|
||||||
|
name: Bump version and sync Changelog to Docker Repo
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -9,35 +10,69 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
bump_and_sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout FileRise
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
- name: Extract version from commit message
|
||||||
path: file-rise
|
id: ver
|
||||||
|
run: |
|
||||||
|
MSG="${{ github.event.head_commit.message }}"
|
||||||
|
if [[ "$MSG" =~ release\((v[0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
|
||||||
|
echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found version: ${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "version=" >> $GITHUB_OUTPUT
|
||||||
|
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update public/js/version.js
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
|
run: |
|
||||||
|
cat > public/js/version.js <<'EOF'
|
||||||
|
// generated by CI
|
||||||
|
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Commit version.js (if changed)
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add public/js/version.js
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore: set APP_VERSION to ${{ steps.ver.outputs.version }}"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checkout filerise-docker
|
- name: Checkout filerise-docker
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: error311/filerise-docker
|
repository: error311/filerise-docker
|
||||||
token: ${{ secrets.PAT_TOKEN }}
|
token: ${{ secrets.PAT_TOKEN }}
|
||||||
path: docker-repo
|
path: docker-repo
|
||||||
|
|
||||||
- name: Copy CHANGELOG.md
|
- name: Copy CHANGELOG.md and write VERSION
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
run: |
|
run: |
|
||||||
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
|
cp CHANGELOG.md docker-repo/CHANGELOG.md
|
||||||
|
echo "${{ steps.ver.outputs.version }}" > docker-repo/VERSION
|
||||||
|
|
||||||
- name: Commit & push
|
- name: Commit & push to docker repo
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
working-directory: docker-repo
|
working-directory: docker-repo
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md VERSION
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
git commit -m "chore: sync CHANGELOG.md and VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||||
git push origin main
|
git push origin main
|
||||||
fi
|
fi
|
||||||
|
|||||||
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/data/
|
||||||
916
CHANGELOG.md
@@ -1,5 +1,917 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 10/25/2025 (v1.6.8)
|
||||||
|
|
||||||
|
release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder
|
||||||
|
|
||||||
|
- Seed `currentFolder` from `localStorage.lastOpenedFolder` (fallback to "root")
|
||||||
|
- Stop eager `loadFileList('root')` on boot; defer initial load to resolved folder
|
||||||
|
- Hide capability-gated actions by default (`#extractZipBtn`, `#createBtn`) to avoid pre-auth flash
|
||||||
|
- Eliminates transient root state when reloading inside a subfolder
|
||||||
|
|
||||||
|
User-visible: refreshing a non-root folder no longer flashes Root items or privileged buttons; app resumes in the last opened folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/25/2025 (v1.6.7)
|
||||||
|
|
||||||
|
release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish
|
||||||
|
|
||||||
|
### 📂 Folder Move (new major feature)
|
||||||
|
|
||||||
|
**Drag & Drop to move folder, use context menu or Move Folder button**
|
||||||
|
|
||||||
|
- Added **Move Folder** support across backend and UI.
|
||||||
|
- New API endpoint: `public/api/folder/moveFolder.php`
|
||||||
|
- Controller and ACL updates to validate scope, ownership, and permissions.
|
||||||
|
- Non-admins can only move within folders they own.
|
||||||
|
- `ACL::renameTree()` re-keys all subtree ACLs on folder rename/move.
|
||||||
|
- Introduced new capabilities:
|
||||||
|
- `canMoveFolder`
|
||||||
|
- `canMove` (UI alias for backward compatibility)
|
||||||
|
- New “Move Folder” button + modal in the UI with full i18n strings (`i18n.js`).
|
||||||
|
- Action button styling and tooltip consistency for all folder actions.
|
||||||
|
|
||||||
|
### 🧱 Drag & Drop / Layout Improvements
|
||||||
|
|
||||||
|
- Fixed **random sidebar → top zone jumps** on refresh.
|
||||||
|
- Cards/panels now **persist exactly where you placed them** (`userZonesSnapshot`)
|
||||||
|
— no unwanted repositioning unless the window is resized below the small-screen threshold.
|
||||||
|
- Added hysteresis around the 1205 px breakpoint to prevent flicker when resizing.
|
||||||
|
- Eliminated the 50 px “ghost” gutter with `clampSidebarWhenEmpty()`:
|
||||||
|
- Sidebar no longer reserves space when collapsed or empty.
|
||||||
|
- Temporarily “unclamps” during drag so drop targets remain accurate and full-width.
|
||||||
|
- Removed forced 800 px height on drag highlight; uses natural flex layout now.
|
||||||
|
- General layout polish — smoother transitions when toggling *Hide/Show Panels*.
|
||||||
|
|
||||||
|
### ☁️ Uploads & UX
|
||||||
|
|
||||||
|
- Stronger folder sanitization and safer base-path handling.
|
||||||
|
- Fixed subfolder creation when uploading directories (now builds under correct parent).
|
||||||
|
- Improved chunk error handling and metadata key correctness.
|
||||||
|
- Clearer success/failure toasts and accurate filename display from server responses.
|
||||||
|
|
||||||
|
### 🔐 Permissions / ACL
|
||||||
|
|
||||||
|
- Simplified file rename checks — now rely solely on granular `ACL::canRename()`.
|
||||||
|
- Updated capability lists to include move/rename operations consistently.
|
||||||
|
|
||||||
|
### 🌐 UI / i18n Enhancements
|
||||||
|
|
||||||
|
- Added i18n strings for new “Move Folder” prompts, modals, and tooltips.
|
||||||
|
- Minor UI consistency tweaks: button alignment, focus states, reduced-motion support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.6)
|
||||||
|
|
||||||
|
release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix
|
||||||
|
|
||||||
|
- dragAndDrop: mount zones toggle beside header logo (absolute, non-scrolling);
|
||||||
|
stop click propagation so it doesn’t trigger the logo link; theme-aware styling
|
||||||
|
- live updates via MutationObserver; snapshot card locations on drop and restore
|
||||||
|
on load (prevents sidebar reset); guard first-run defaults with
|
||||||
|
`layoutDefaultApplied_v1`; small/medium layout tweaks & refactors.
|
||||||
|
- CSS: switch toggle icon to CSS variable (`--toggle-icon-color`) with dark-mode
|
||||||
|
override; remove hardcoded `!important`.
|
||||||
|
- API (capabilities.php): remove unused `disableUpload` flag from `canUpload`
|
||||||
|
and flags payload to resolve undefined variable warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.5)
|
||||||
|
|
||||||
|
release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php
|
||||||
|
|
||||||
|
- Fix undefined variable: use $disableUpload consistently
|
||||||
|
- Harden flag read: (bool)($perms['disableUpload'] ?? false)
|
||||||
|
- Prevents warning and ensures Upload capability is computed correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.4)
|
||||||
|
|
||||||
|
release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks
|
||||||
|
|
||||||
|
- Add public/js/version.js (default "dev") and load it before main.js.
|
||||||
|
- adminPanel.js: replace hard-coded string with `window.APP_VERSION || "dev"`.
|
||||||
|
- public/.htaccess: add no-cache for js/version.js
|
||||||
|
- GitHub Actions: replace sync job with “Bump version and sync Changelog to Docker Repo”.
|
||||||
|
- Parse commit msg `release(vX.Y.Z)` -> set step output `version`.
|
||||||
|
- Write `public/js/version.js` with `window.APP_VERSION = '<version>'`.
|
||||||
|
- Commit/push version.js if changed.
|
||||||
|
- Mirror CHANGELOG.md to filerise-docker and write a VERSION file with `<version>`.
|
||||||
|
- Guard all steps with `if: steps.ver.outputs.version != ''` to no-op on non-release commits.
|
||||||
|
|
||||||
|
This wires the UI version label to CI, keeps dev builds showing “dev”, and feeds the Docker repo with CHANGELOG + VERSION for builds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.3)
|
||||||
|
|
||||||
|
release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58)
|
||||||
|
|
||||||
|
Drag & Drop - Upload/Folder Management Cards layout
|
||||||
|
|
||||||
|
- Persist panel locations across refresh; snapshot + restore when collapsing/expanding.
|
||||||
|
- Unified “zones” toggle; header-icon mode no longer loses card state.
|
||||||
|
- Responsive: auto-move sidebar cards to top on small screens; restore on resize.
|
||||||
|
- Better top-zone placeholder/cleanup during drag; tighter header modal sizing.
|
||||||
|
- Safer order saving + deterministic placement for upload/folder cards.
|
||||||
|
|
||||||
|
Admin Panel – Folder Access
|
||||||
|
|
||||||
|
- Fix: newly created folders now appear without a full page refresh (cache-busted `getFolderList`).
|
||||||
|
- Show admin users in the list with full access pre-applied and inputs disabled (read-only).
|
||||||
|
- Skip sending updates for admins when saving grants.
|
||||||
|
- “Folder” column now has its own horizontal scrollbar so long names / “Inherited from …” are never cut off.
|
||||||
|
|
||||||
|
Admin Panel – User Permissions (flags)
|
||||||
|
|
||||||
|
- Show admins (marked as Admin) with all switches disabled; exclude from save payload.
|
||||||
|
- Clarified helper text (account-level vs per-folder).
|
||||||
|
|
||||||
|
UI/Styling
|
||||||
|
|
||||||
|
- Added `.folder-cell` scroller in ACL table; improved dark-mode scrollbar/thumb.
|
||||||
|
|
||||||
|
Docs
|
||||||
|
|
||||||
|
- README edits:
|
||||||
|
- Clarified PUID/PGID mapping and host/NAS ownership requirements for mounted volumes.
|
||||||
|
- Environment variables section added
|
||||||
|
- CHOWN_ON_START additional details
|
||||||
|
- Admin details
|
||||||
|
- Upgrade section added
|
||||||
|
- 💖 Sponsor FileRise section added
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/23/2025 (v1.6.2)
|
||||||
|
|
||||||
|
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
||||||
|
|
||||||
|
- Add zh-CN locale to i18n.js with full key set.
|
||||||
|
- Introduce chinese_simplified label key across locales.
|
||||||
|
- Added some missing labels
|
||||||
|
- Update language selector mapping to include zh-CN (English/Spanish/French/German/简体中文).
|
||||||
|
- Wire zh-CN into Auth/User Panel (authModals) language dropdown.
|
||||||
|
- Fallback-safe rendering for language names when a key is missing.
|
||||||
|
|
||||||
|
ui: fix “Change Password” button sizing in User Panel
|
||||||
|
|
||||||
|
- Keep consistent padding and font size for cleaner layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/23/2025 (v1.6.1)
|
||||||
|
|
||||||
|
feat(ui): unified zone toggle + polished interactions for sidebar/top cards
|
||||||
|
|
||||||
|
- Add floating toggle button styling (hover lift, press, focus ring, ripple)
|
||||||
|
for #zonesToggleFloating and #sidebarToggleFloating (CSS).
|
||||||
|
- Ensure icons are visible and centered; enforce consistent sizing/color.
|
||||||
|
- Introduce unified “zones collapsed” state persisted via `localStorage.zonesCollapsed`.
|
||||||
|
- Update dragAndDrop.js to:
|
||||||
|
- manage a single floating toggle for both Sidebar and Top Zone
|
||||||
|
- keep toggle visible when cards are in Top Zone; hide only when both cards are in Header
|
||||||
|
- rotate icon 90° when both cards are in Top Zone and panels are open
|
||||||
|
- respect collapsed state during DnD flows and on load
|
||||||
|
- preserve original DnD behaviors and saved orders (sidebar/header)
|
||||||
|
- Minor layout/visibility fixes during drag (clear temp heights; honor collapsed).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- No breaking API changes; existing `sidebarOrder` / `headerOrder` continue to work.
|
||||||
|
- New key: `zonesCollapsed` (string '0'/'1') controls visibility of Sidebar + Top Zone.
|
||||||
|
|
||||||
|
UX:
|
||||||
|
|
||||||
|
- Floating toggle feels more “material”: subtle hover elevation, press feedback,
|
||||||
|
focus ring, and click ripple to restore the prior interactive feel.
|
||||||
|
- Icons remain legible on white (explicit color set), centered in the circular button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/22/2025 (v1.6.0)
|
||||||
|
|
||||||
|
feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
|
||||||
|
|
||||||
|
- Add granular ACL buckets: create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
|
||||||
|
- Implement ACL::canX helpers and expand upsert/explicit APIs (preserve read_own)
|
||||||
|
- Enforce “write no longer implies read” in canRead; use granular gates for write-ish ops
|
||||||
|
- WebDAV: use canDelete for DELETE, canUpload/canEdit + disableUpload for PUT; enforce ownership on overwrite
|
||||||
|
- Folder create: require Manage/Owner on parent; normalize paths; seed ACL; rollback on failure
|
||||||
|
- FileController: refactor copy/move/rename/delete/extract to granular gates + folder-scope checks + own-only ownership enforcement
|
||||||
|
- Capabilities API: compute effective actions with scope + readOnly/disableUpload; protect root
|
||||||
|
- Admin Panel (v1.6.0): new Folder Access editor with granular caps, inheritance hints, bulk toggles, and UX validations
|
||||||
|
- getFileList: keep root visible but inert for users without visibility; apply own-only filtering server-side
|
||||||
|
- Bump version to v1.6.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/20/2025 (v1.5.3)
|
||||||
|
|
||||||
|
security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
|
||||||
|
|
||||||
|
### fileListView.js (v1.5.3)
|
||||||
|
|
||||||
|
- Restore master “Select All” checkbox behavior and row highlighting.
|
||||||
|
- Keep selection working with own-only filtered lists.
|
||||||
|
- Build preview/thumb URLs via secure API endpoints; avoid direct /uploads.
|
||||||
|
- Minor UI polish: slider wiring and pagination focus handling.
|
||||||
|
|
||||||
|
### FileController.php (v1.5.3)
|
||||||
|
|
||||||
|
- Add enforceFolderScope($folder, $user, $perms, $need) and apply across actions.
|
||||||
|
- Copy/Move: require read on source, write on destination; apply scope on both.
|
||||||
|
- When user only has read_own, enforce per-file ownership (uploader==user).
|
||||||
|
- Extract ZIP: require write + scope; consistent 403 messages.
|
||||||
|
- Save/Rename/Delete/Create: tighten ACL checks; block dangerous extensions; consistent CSRF/Auth handling and error codes.
|
||||||
|
- Download/ZIP: honor read vs read_own; own-only gates by uploader; safer headers.
|
||||||
|
|
||||||
|
### FolderController.php (v1.5.3)
|
||||||
|
|
||||||
|
- Align with ACL: enforce folder-scope for non-admins; require owner or bypass for destructive ops.
|
||||||
|
- Create/Rename/Delete: gate by write on parent/target + ownership when needed.
|
||||||
|
- Share folder link: require share capability; forbid root sharing for non-admins; validate expiry; optional password.
|
||||||
|
- Folder listing: return only folders user can fully view or has read_own.
|
||||||
|
- Shared downloads/uploads: stricter validation, headers, and error handling.
|
||||||
|
|
||||||
|
This commits a consistent, least-privilege ACL model (owners/read/write/share/read_own), fixes bulk-select in the UI, and closes scope/ownership gaps across file & folder actions.
|
||||||
|
|
||||||
|
feat(dnd): default cards to sidebar on medium screens when no saved layout
|
||||||
|
|
||||||
|
- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1)
|
||||||
|
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
|
||||||
|
- Keeps user changes persistent; no override once a layout exists
|
||||||
|
|
||||||
|
feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads
|
||||||
|
|
||||||
|
- Build the editor modal immediately and wire close (✖, Close button, and Esc) before any async work, so the UI is always dismissible.
|
||||||
|
- Restore MODE_URL and add normalizeModeName() to resolve aliases (text/html → htmlmixed, php → application/x-httpd-php).
|
||||||
|
- Add SRI for each lazily loaded mode (MODE_SRI) and apply integrity/crossOrigin on script tags; switch to async and improved error messages.
|
||||||
|
- Introduce MODE_LOAD_TIMEOUT_MS=2500 and Promise.race() to init in text/plain if a mode is slow; auto-upgrade to the real mode once it arrives.
|
||||||
|
- Graceful fallback: if CodeMirror core isn’t present, keep textarea, enable Save, and proceed.
|
||||||
|
- Minor UX: disable Save until the editor is ready, support theme toggling, better resize handling, and font size controls without blocking.
|
||||||
|
|
||||||
|
Security: Locks CDN mode scripts with SRI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/19/2025 (v1.5.2)
|
||||||
|
|
||||||
|
fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec
|
||||||
|
|
||||||
|
- adminPanel.js
|
||||||
|
- Fix modal open/close reliability and stacking order
|
||||||
|
- Prevent background scroll while modal is open
|
||||||
|
- Tidy focus/keyboard handling for better UX
|
||||||
|
|
||||||
|
- style.css
|
||||||
|
- Polish styles for Folder Access + Users views (spacing, tables, badges)
|
||||||
|
- Improve responsiveness and visual consistency
|
||||||
|
|
||||||
|
- api.php
|
||||||
|
- Update Redoc SRI hash and pin to the current bundle URL
|
||||||
|
|
||||||
|
- OpenAPI
|
||||||
|
- Add/refresh inline @OA annotations across endpoints
|
||||||
|
- Introduce src/openapi/Components.php with base Info/Server,
|
||||||
|
common responses, and shared components
|
||||||
|
- Regenerate and commit openapi.json.dist
|
||||||
|
|
||||||
|
- public/js/adminPanel.js
|
||||||
|
- public/css/style.css
|
||||||
|
- public/api.php
|
||||||
|
- src/openapi/Components.php
|
||||||
|
- openapi.json.dist
|
||||||
|
- public/api/** (annotated endpoints)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/19/2025 (v1.5.1)
|
||||||
|
|
||||||
|
fix(config/ui): serve safe public config to non-admins; init early; gate trash UI to admins; dynamic title; demo toast (closes #56)
|
||||||
|
|
||||||
|
Regular users were getting 403s from `/api/admin/getConfig.php`, breaking header title and login option rendering. Issue #56 tracks this.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
- **AdminController::getConfig**
|
||||||
|
- Return a **public, non-sensitive subset** of config for everyone (incl. unauthenticated and non-admin users): `header_title`, minimal `loginOptions` (disable* flags only), `globalOtpauthUrl`, `enableWebDAV`, `sharedMaxUploadSize`, and OIDC `providerUrl`/`redirectUri`.
|
||||||
|
- For **admins**, merge in admin-only fields (`authBypass`, `authHeaderName`).
|
||||||
|
- Never expose secrets or client IDs.
|
||||||
|
- **auth.js**
|
||||||
|
- `loadAdminConfigFunc()` now robustly handles empty/204 responses, writes sane defaults, and sets `document.title` from `header_title`.
|
||||||
|
- `showToast()` override: on `demo.filerise.net` shows a longer demo-creds toast; keeps TOTP “don’t nag” behavior.
|
||||||
|
- **main.js**
|
||||||
|
- Call `loadAdminConfigFunc()` early during app init.
|
||||||
|
- Run `setupTrashRestoreDelete()` **only for admins** (based on `localStorage.isAdmin`).
|
||||||
|
- **adminPanel.js**
|
||||||
|
- Bump visible version to **v1.5.1**.
|
||||||
|
- **index.html**
|
||||||
|
- Keep `<title>FileRise</title>` static; runtime title now driven by `loadAdminConfigFunc()`.
|
||||||
|
|
||||||
|
### Security v1.5.1
|
||||||
|
|
||||||
|
- Prevents info disclosure by strictly limiting non-admin fields.
|
||||||
|
- Avoids noisy 403 for regular users while keeping admin-only data protected.
|
||||||
|
|
||||||
|
### QA
|
||||||
|
|
||||||
|
- As a non-admin:
|
||||||
|
- Opening the app no longer triggers a 403 on `getConfig.php`.
|
||||||
|
- Header title and login options render; document tab title updates to configured `header_title`.
|
||||||
|
- Trash/restore UI is not initialized.
|
||||||
|
- As an admin:
|
||||||
|
- Admin Panel loads extra fields; trash/restore UI initializes.
|
||||||
|
- Title updates correctly.
|
||||||
|
- On `demo.filerise.net`:
|
||||||
|
- Pre-login toast shows demo credentials for ~12s.
|
||||||
|
|
||||||
|
Closes #56.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/17/2025 (v1.5.0)
|
||||||
|
|
||||||
|
Security and permission model overhaul. Tightens access controls with explicit, server‑side ACL checks across controllers and WebDAV. Introduces `read_own` for own‑only visibility and separates view from write so uploaders can’t automatically see others’ files. Fixes session warnings and aligns the admin UI with the new capabilities.
|
||||||
|
|
||||||
|
> **Security note**
|
||||||
|
> This release contains security hardening based on a private report (tracked via a GitHub Security Advisory, CVE pending). For responsible disclosure, details will be published alongside the advisory once available. Users should upgrade promptly.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **ACL**
|
||||||
|
- New `read_own` bucket (own‑only visibility) alongside `owners`, `read`, `write`, `share`.
|
||||||
|
- **Semantic change:** `write` no longer implies `read`.
|
||||||
|
- `ACL::applyUserGrantsAtomic()` to atomically set per‑folder grants (`view`, `viewOwn`, `upload`, `manage`, `share`).
|
||||||
|
- `ACL::purgeUser($username)` to remove a user from all buckets (used when deleting a user).
|
||||||
|
- Auto‑heal `folder_acl.json` (ensure `root` exists; add missing buckets; de‑dupe; normalize types).
|
||||||
|
- More robust admin detection (role flag or session/admin user).
|
||||||
|
|
||||||
|
- **Controllers**
|
||||||
|
- `FileController`: ACL + ownership enforcement for list, download, zip download, extract, move, copy, rename, create, save, tag edit, and share‑link creation. `getFileList()` now filters to the caller’s uploads when they only have `read_own` (no `read`).
|
||||||
|
- `UploadController`: requires `ACL::canWrite()` for the target folder; CSRF refresh path improved; admin bypass intact.
|
||||||
|
- `FolderController`: listing filtered by `ACL::canRead()`; optional parent filter preserved; removed name‑based ownership assumptions.
|
||||||
|
|
||||||
|
- **Admin UI**
|
||||||
|
- Folder Access grid now includes **View (own)**; bulk toolbar actions; column alignment fixes; more space for folder names; dark‑mode polish.
|
||||||
|
|
||||||
|
- **WebDAV**
|
||||||
|
- WebDAV now enforces ACL consistently: listing requires `read` (or `read_own` ⇒ shows only caller’s files); writes require `write`.
|
||||||
|
- Removed legacy “folderOnly” behavior — ACL is the single source of truth.
|
||||||
|
- Metadata/uploader is preserved through existing models.
|
||||||
|
|
||||||
|
### Behavior changes (⚠️ Breaking)
|
||||||
|
|
||||||
|
- **`write` no longer implies `read`.**
|
||||||
|
- If you want uploaders to see all files in a folder, also grant **View (all)** (`read`).
|
||||||
|
- If you want uploaders to see only their own files, grant **View (own)** (`read_own`).
|
||||||
|
|
||||||
|
- **Removed:** legacy `folderOnly` view logic in favor of ACL‑based access.
|
||||||
|
|
||||||
|
### Upgrade checklist
|
||||||
|
|
||||||
|
1. Review **Folder Access** in the admin UI and grant **View (all)** or **View (own)** where appropriate.
|
||||||
|
2. For users who previously had “upload but not view,” confirm they now have **Upload** + **View (own)** (or add **View (all)** if intended).
|
||||||
|
3. Verify WebDAV behavior for representative users:
|
||||||
|
- `read` shows full listings; `read_own` lists only the caller’s files.
|
||||||
|
- Writes only succeed where `write` is granted.
|
||||||
|
4. Confirm admin can upload/move/zip across all folders (regression tested).
|
||||||
|
|
||||||
|
### Affected areas
|
||||||
|
|
||||||
|
- `config/config.php` — session/cookie initialization ordering; proxy header handling.
|
||||||
|
- `src/lib/ACL.php` — new bucket, semantics, healing, purge, admin detection.
|
||||||
|
- `src/controllers/FileController.php` — ACL + ownership gates across operations.
|
||||||
|
- `src/controllers/UploadController.php` — write checks + CSRF refresh handling.
|
||||||
|
- `src/controllers/FolderController.php` — ACL‑filtered listing and parent scoping.
|
||||||
|
- `public/api/admin/acl/*.php` — includes `viewOwn` round‑trip and sanitization.
|
||||||
|
- `public/js/*` & CSS — folder access grid alignment and layout fixes.
|
||||||
|
- `src/webdav/*` & `public/webdav.php` — ACL‑aware WebDAV server.
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
- Security report acknowledged privately and will be credited in the published advisory.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- fix(folder-model): resolve syntax error, unexpected token
|
||||||
|
- Deleted accidental second `<?php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/15/2025 (v1.4.0)
|
||||||
|
|
||||||
|
feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend
|
||||||
|
|
||||||
|
### Security / Hardening
|
||||||
|
|
||||||
|
- Tightened ownership checks across file ops; introduced centralized permission helper to avoid falsey-permissions bugs.
|
||||||
|
- Consistent CSRF verification on mutating endpoints; stricter input validation using `REGEX_*` and `basename()` trims.
|
||||||
|
- Safer path handling & metadata reads; reduced noisy error surfaces; consistent HTTP codes (401/403/400/500).
|
||||||
|
- Adds defense-in-depth to reduce risk of unauthorized file manipulation.
|
||||||
|
|
||||||
|
### Config (`config.php`)
|
||||||
|
|
||||||
|
- Add optional defaults for new permissions (all optional):
|
||||||
|
- `DEFAULT_BYPASS_OWNERSHIP` (bool)
|
||||||
|
- `DEFAULT_CAN_SHARE` (bool)
|
||||||
|
- `DEFAULT_CAN_ZIP` (bool)
|
||||||
|
- `DEFAULT_VIEW_OWN_ONLY` (bool)
|
||||||
|
- Keep existing behavior unless explicitly enabled (bypassOwnership typically true for admins; configurable per user).
|
||||||
|
|
||||||
|
### Controllers
|
||||||
|
|
||||||
|
#### `FileController.php`
|
||||||
|
|
||||||
|
- New lightweight `loadPerms($username)` helper that **always** returns an array; prevents type errors when permissions are missing.
|
||||||
|
- Ownership checks now respect: `isAdmin(...) || perms['bypassOwnership'] || DEFAULT_BYPASS_OWNERSHIP`.
|
||||||
|
- Gate sharing/zip operations by `perms['canShare']` / `perms['canZip']`.
|
||||||
|
- Implement `viewOwnOnly` filtering in `getFileList()` (supports both map and list shapes).
|
||||||
|
- Normalize and validate folder/file input; enforce folder-only scope for writes/moves/copies where applicable.
|
||||||
|
- Improve error handling: convert warnings/notices to exceptions within try/catch; consistent JSON error payloads.
|
||||||
|
- Add missing `require_once PROJECT_ROOT . '/src/models/UserModel.php'` to fix “Class userModel not found”.
|
||||||
|
- Download behavior: inline for images, attachment for others; owner/bypass logic applied.
|
||||||
|
|
||||||
|
#### `FolderController.php`
|
||||||
|
|
||||||
|
- `createShareFolderLink()` gated by `canShare`; validates duration (cap at 1y), folder names, password optional.
|
||||||
|
- (If present) folder share deletion/read endpoints wired to new permission model.
|
||||||
|
|
||||||
|
#### `AdminController.php`
|
||||||
|
|
||||||
|
- `getConfig()` remains admin-only; returns safe subset. (Non-admins now simply receive 403; client can ignore.)
|
||||||
|
|
||||||
|
#### `UserController.php`
|
||||||
|
|
||||||
|
- Plumbs new permission fields in get/set endpoints (`folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**).
|
||||||
|
- Normalizes username keys and defaults to prevent undefined-index errors.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
#### `FileModel.php` / `FolderModel.php`
|
||||||
|
|
||||||
|
- Respect caller’s effective permissions (controllers pass-through); stricter input normalization.
|
||||||
|
- ZIP creation/extraction guarded via `canZip`; metadata updates consistent; safer temp paths.
|
||||||
|
- Improved return shapes and error messages (never return non-array on success paths).
|
||||||
|
|
||||||
|
#### `AdminModel.php`
|
||||||
|
|
||||||
|
- Reads/writes admin config with new `loginOptions` intact; never exposes sensitive OIDC secrets to the client layer.
|
||||||
|
|
||||||
|
#### `UserModel.php`
|
||||||
|
|
||||||
|
- Store/load the 4 new flags; helper ensures absent users/fields don’t break caller; returns normalized arrays.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### `main.js`
|
||||||
|
|
||||||
|
- Initialize after CSRF; keep dark-mode persistence, welcome toast, drag-over UX.
|
||||||
|
- Leaves `loadAdminConfigFunc()` call in place (non-admins may 403; harmless).
|
||||||
|
|
||||||
|
#### `adminPanel.js` (v1.4.0)
|
||||||
|
|
||||||
|
- New **User Permissions** UI with collapsible rows per user:
|
||||||
|
- Shows username; clicking expands a checkbox matrix.
|
||||||
|
- Permissions: `folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**.
|
||||||
|
- **Manage Shared Links** section reads folder & file share metadata; delete buttons per token.
|
||||||
|
- Refined modal sizing & dark-mode styling; consistent toasts; unsaved-changes confirmation.
|
||||||
|
- Keeps 403 from `/api/admin/getConfig.php` for non-admins (acceptable; no UI break).
|
||||||
|
|
||||||
|
### Breaking change
|
||||||
|
|
||||||
|
- Non-admin users without `bypassOwnership` can no longer create/rename/move/copy/delete/share/zip files they don’t own.
|
||||||
|
- If legacy behavior depended on broad access, set `bypassOwnership` per user or use `DEFAULT_BYPASS_OWNERSHIP=true` in `config.php`.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- Add the new flags to existing users in your permissions store (or rely on `config.php` defaults).
|
||||||
|
- Verify admin accounts have either `isAdmin` or `bypassOwnership`/`canShare`/`canZip` as desired.
|
||||||
|
- Optionally tune `DEFAULT_*` constants for instance-wide defaults.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Hardened access controls for file operations based on an external security report.
|
||||||
|
Details are withheld temporarily to protect users; a full advisory will follow after wider adoption of the fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/8/2025 (no new version)
|
||||||
|
|
||||||
|
chore: set up CI, add compose, tighten ignores, refresh README
|
||||||
|
|
||||||
|
- CI: add workflow to lint PHP (php -l), validate/audit composer,
|
||||||
|
shellcheck *.sh, hadolint Dockerfile, and sanity-check JSON/YAML; supports
|
||||||
|
push/PR/manual dispatch.
|
||||||
|
- Docker: add docker-compose.yml for local dev (8080:80, volumes/env).
|
||||||
|
- .dockerignore: exclude VCS, build artifacts, OS/editor junk, logs, temp dirs,
|
||||||
|
node_modules, resources/, etc. to slim build context.
|
||||||
|
- .gitignore: ignore .env, editor/system files, build caches, optional data/.
|
||||||
|
- README: update badges (CI, release, license), inline demo creds, add quick
|
||||||
|
links, tighten WebDAV section (Windows HTTPS note + wiki link), reduced length and star
|
||||||
|
history chart.
|
||||||
|
|
||||||
|
## Changes 10/7/2025 (no new version)
|
||||||
|
|
||||||
|
feat(startup): stream error.log to console by default; add LOG_STREAM selector
|
||||||
|
|
||||||
|
- Touch error/access logs on start so tail can attach immediately
|
||||||
|
- Add LOG_STREAM=error|access|both|none (default: error)
|
||||||
|
- Tail with `-n0 -F` to follow new entries only and survive rotations
|
||||||
|
- Keep access.log on disk but don’t spam console unless requested
|
||||||
|
- (Unraid) Optional env var template entry for LOG_STREAM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/6/2025 v1.3.15
|
||||||
|
|
||||||
|
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||||
|
|
||||||
|
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||||
|
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||||
|
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||||
|
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/5/2025 v1.3.14
|
||||||
|
|
||||||
|
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||||
|
|
||||||
|
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
|
||||||
|
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
|
||||||
|
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||||
|
- Normalize file perms to 0664 after write
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.13
|
||||||
|
|
||||||
|
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||||
|
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||||
|
chore(scanner): skip profile_pics subtree during scans
|
||||||
|
|
||||||
|
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||||
|
- prevents double slashes in metadata paths; respects app timezone
|
||||||
|
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||||
|
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||||
|
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||||
|
- Prunes stale entries for files that no longer exist
|
||||||
|
- Skips uploads/trash and symlinks
|
||||||
|
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||||
|
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||||
|
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||||
|
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.12
|
||||||
|
|
||||||
|
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||||
|
|
||||||
|
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||||
|
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||||
|
- SCAN_ON_START unchanged, with non-root fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.11
|
||||||
|
|
||||||
|
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||||
|
|
||||||
|
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||||
|
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.10
|
||||||
|
|
||||||
|
Fix: index externally added files on startup; harden start.sh (#46)
|
||||||
|
|
||||||
|
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||||
|
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||||
|
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||||
|
- Make ServerName idempotent; avoid duplicate entries
|
||||||
|
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||||
|
|
||||||
|
No behavior change unless SCAN_ON_START=true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/27/2025 v1.3.9
|
||||||
|
|
||||||
|
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||||
|
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
|
||||||
|
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
|
||||||
|
- Documentation for configuring CIFS share mounting and scanning
|
||||||
|
|
||||||
|
- Clipboard Paste Upload Support (single image):
|
||||||
|
- Users can now paste images directly into the FileRise web interface.
|
||||||
|
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
|
||||||
|
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/26/2025
|
||||||
|
|
||||||
|
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
|
||||||
|
- Ensures the whole name can’t end in a space or period.
|
||||||
|
- Blocks Windows device names.
|
||||||
|
|
||||||
|
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/23/2025 v1.3.8
|
||||||
|
|
||||||
|
- **Folder-strip context menu**
|
||||||
|
- Enabled right-click on items in the new folder strip (above file list) to open the same “Create / Rename / Share / Delete Folder” menu as in the main folder tree.
|
||||||
|
- Bound `contextmenu` event on each `.folder-item` in `loadFileList` to:
|
||||||
|
- Prevent the default browser menu
|
||||||
|
- Highlight the clicked folder-strip item
|
||||||
|
- Invoke `showFolderManagerContextMenu` with menu entries:
|
||||||
|
- Create Folder
|
||||||
|
- Rename Folder
|
||||||
|
- Share Folder (passes the strip’s `data-folder` value)
|
||||||
|
- Delete Folder
|
||||||
|
- Ensured menu actions are wrapped in arrow functions (`() => …`) so they fire only on menu-item click, not on render.
|
||||||
|
|
||||||
|
- Refactored folder-strip injection in `fileListView.js` to:
|
||||||
|
- Mark each strip item as `draggable="true"` (for drag-and-drop)
|
||||||
|
- Add `el.addEventListener("contextmenu", …)` alongside existing click/drag handlers
|
||||||
|
- Clean up global click listener for hiding the context menu
|
||||||
|
|
||||||
|
- Prevented premature invocation of `openFolderShareModal` by switching to `action: () => openFolderShareModal(dest)` instead of calling it directly.
|
||||||
|
|
||||||
|
- **Create File/Folder dropdown**
|
||||||
|
- Replaced standalone “Create File” button with a combined dropdown button in the actions toolbar.
|
||||||
|
- New markup
|
||||||
|
- Wired up JS handlers in `fileActions.js`:
|
||||||
|
- `#createFileOption` → `openCreateFileModal()`
|
||||||
|
- `#createFolderOption` → `document.getElementById('createFolderModal').style.display = 'block'`
|
||||||
|
- Toggled `.dropdown-menu` visibility on button click, and closed on outside click.
|
||||||
|
- Applied dark-mode support: dropdown background and text colors switch with `.dark-mode` class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/22/2025 v1.3.7
|
||||||
|
|
||||||
|
- `.folder-strip-container .folder-name` css added to center text below folder material icon.
|
||||||
|
- Override file share_url to always use current origin
|
||||||
|
- Update `fileList` css to keep file name wrapping tight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/21/2025
|
||||||
|
|
||||||
|
- **Drag & Drop to Folder Strip**
|
||||||
|
- Enabled dragging files from the file list directly onto the folder-strip items.
|
||||||
|
- Hooked up `folderDragOverHandler`, `folderDragLeaveHandler`, and `folderDropHandler` to `.folder-strip-container .folder-item`.
|
||||||
|
- On drop, files are moved via `/api/file/moveFiles.php` and the file list is refreshed.
|
||||||
|
|
||||||
|
- **Restore files from trash Toast Message**
|
||||||
|
- Changed the restore handlers so that the toast always reports the actual file(s) restored (e.g. “Restored file: foo.txt”) instead of “No trash record found.”
|
||||||
|
- Removed reliance on backend message payload and now generate the confirmation text client-side based on selected items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/20/2025 v1.3.6
|
||||||
|
|
||||||
|
- **domUtils.js**
|
||||||
|
- `updateFileActionButtons`
|
||||||
|
- Hide selection buttons (`Delete Files`, `Copy Files`, `Move Files` & `Download ZIP`) until file is selected.
|
||||||
|
- Hide `Extract ZIP` until selecting zip files
|
||||||
|
- Hide `Create File` button when file list items are selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/19/2025 v1.3.5
|
||||||
|
|
||||||
|
### Added Folder strip & Create File
|
||||||
|
|
||||||
|
- **Folder strip in file list**
|
||||||
|
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
|
||||||
|
- Filters to only direct children of the current folder, hiding `profile_pics` and `trash`.
|
||||||
|
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
|
||||||
|
- Clicking a folder in the strip updates:
|
||||||
|
- the breadcrumb (via `updateBreadcrumbTitle`)
|
||||||
|
- the tree selection highlight
|
||||||
|
- reloads `loadFileList` for the chosen folder.
|
||||||
|
|
||||||
|
- **Create File feature**
|
||||||
|
- New “Create New File” button added to the file-actions toolbar and context menu.
|
||||||
|
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
|
||||||
|
- Creates an empty file if it doesn’t already exist.
|
||||||
|
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
|
||||||
|
- `fileActions.js`:
|
||||||
|
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
|
||||||
|
- Added translations for `create_new_file` and `newfile_placeholder`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changees 5/15/2025
|
||||||
|
|
||||||
|
### Drag‐and‐Drop Upload extended to File List
|
||||||
|
|
||||||
|
- **Forward file‐list drops**
|
||||||
|
Dropping files onto the file‐list area (`#fileListContainer`) now re‐dispatches the same `drop` event to the upload card’s drop zone (`#uploadDropArea`)
|
||||||
|
- **Visual feedback**
|
||||||
|
Added a `.drop-hover` class on `#fileListContainer` during drag‐over for a dashed‐border + light‐background hover state to indicate it accepts file drops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/14/2025 v1.3.4
|
||||||
|
|
||||||
|
### 1. Button Grouping (Bootstrap)
|
||||||
|
|
||||||
|
- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI.
|
||||||
|
- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing.
|
||||||
|
|
||||||
|
### 2. Header Dropdown Replacement
|
||||||
|
|
||||||
|
- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header.
|
||||||
|
- Dropdown toggle now shows:
|
||||||
|
- **Profile picture** (if set) or the Material “account_circle” icon
|
||||||
|
- **Username** text (between avatar and caret)
|
||||||
|
- Down-arrow caret span.
|
||||||
|
|
||||||
|
### 3. Menu Items Moved to Dropdown
|
||||||
|
|
||||||
|
- Moved previously standalone header buttons into the dropdown menu:
|
||||||
|
- **User Panel** opens the modal
|
||||||
|
- **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net`
|
||||||
|
- **API Docs** calls `openApiModal()`
|
||||||
|
- **Logout** calls `triggerLogout()`
|
||||||
|
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
||||||
|
|
||||||
|
### 4. Profile Picture Support
|
||||||
|
|
||||||
|
- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`.
|
||||||
|
- On **Open User Panel**, display:
|
||||||
|
- Default avatar if none set
|
||||||
|
- Current profile picture if available
|
||||||
|
- In the **User Panel** modal:
|
||||||
|
- Stylish “edit” overlay icon on the avatar to launch file picker
|
||||||
|
- Auto-upload on file selection (no “Save” button click needed)
|
||||||
|
- Preview updates immediately and header avatar refreshes live
|
||||||
|
- Persisted in `users.txt` and re-fetched via `getCurrentUser.php`
|
||||||
|
|
||||||
|
### 5. API Docs & Logout Relocation
|
||||||
|
|
||||||
|
- Removed API Docs from User Panel
|
||||||
|
- Removed “Logout” buttons from the header toolbar.
|
||||||
|
- Both are now menu entries in the **User Dropdown**.
|
||||||
|
|
||||||
|
### 6. Admin Panel Conditional
|
||||||
|
|
||||||
|
- The **Admin Panel** button was:
|
||||||
|
- Kept in the dropdown only when `data.isAdmin`
|
||||||
|
- Removed entirely elsewhere.
|
||||||
|
|
||||||
|
### 7. Utility & Styling Tweaks
|
||||||
|
|
||||||
|
- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash.
|
||||||
|
- Hidden the scrollbar in the User Panel modal via:
|
||||||
|
- Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`)
|
||||||
|
- Global/WebKit rule for `::-webkit-scrollbar { display: none; }`
|
||||||
|
- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support.
|
||||||
|
|
||||||
|
### 8. File/List View & Gallery View Sliders
|
||||||
|
|
||||||
|
- **Unified “View‐Mode” Slider**
|
||||||
|
Added a single slider panel (`#viewSliderContainer`) in the file‐list actions toolbar that switches behavior based on the current view mode:
|
||||||
|
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
|
||||||
|
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
|
||||||
|
- Persists the chosen height in `localStorage`.
|
||||||
|
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
|
||||||
|
- Updates the grid’s `grid-template-columns: repeat(N, 1fr)`.
|
||||||
|
- Persists the chosen column count in `localStorage`.
|
||||||
|
|
||||||
|
- **Injection Point**
|
||||||
|
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
|
||||||
|
|
||||||
|
- **Live Updates**
|
||||||
|
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full re‐render.
|
||||||
|
|
||||||
|
- **Styling & Alignment**
|
||||||
|
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
|
||||||
|
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
|
||||||
|
|
||||||
|
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
|
||||||
|
|
||||||
|
**openUserPanel**
|
||||||
|
|
||||||
|
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
|
||||||
|
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
|
||||||
|
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
|
||||||
|
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
|
||||||
|
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
|
||||||
|
|
||||||
|
**updateAuthenticatedUI**
|
||||||
|
|
||||||
|
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
|
||||||
|
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
|
||||||
|
- **Dropdown rebuild logic** tweaked to update the toggle’s innerHTML with both avatar and username on every call.
|
||||||
|
|
||||||
|
**UserModel::getUser**
|
||||||
|
|
||||||
|
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
|
||||||
|
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
|
||||||
|
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
|
||||||
|
|
||||||
|
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
|
||||||
|
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025
|
||||||
|
|
||||||
|
### Docker 🐳
|
||||||
|
|
||||||
|
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`’s `sed -i` updates to `config.php` work reliably
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025 v1.3.3
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- **Admin API** (`updateConfig.php`):
|
||||||
|
- Now merges incoming payload onto existing on-disk settings instead of overwriting blanks.
|
||||||
|
- Preserves `clientId`, `clientSecret`, `providerUrl` and `redirectUri` when those fields are omitted or empty in the request.
|
||||||
|
|
||||||
|
- **Admin API** (`getConfig.php`):
|
||||||
|
- Returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||||
|
|
||||||
|
- **Frontend** (`auth.js`):
|
||||||
|
- Update UI based on merged loginOptions from the server, ensuring blank or missing fields no longer revert your existing config.
|
||||||
|
|
||||||
|
- **Auth API** (`auth.php`):
|
||||||
|
- Added `$oidc->addScope(['openid','profile','email']);` to OIDC flow. (This should resolve authentik issue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025 v1.3.2
|
||||||
|
|
||||||
|
### config/config.php
|
||||||
|
|
||||||
|
- Added a default `define('AUTH_BYPASS', false)` at the top so the constant always exists.
|
||||||
|
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
|
||||||
|
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
|
||||||
|
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
|
||||||
|
- Inserted a **proxy-only auto-login** block before the usual session/auth checks:
|
||||||
|
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
|
||||||
|
- Relax filename validation regex to allow broader Unicode and special chars
|
||||||
|
|
||||||
|
### src/controllers/AdminController.php
|
||||||
|
|
||||||
|
- Ensured the returned `loginOptions` object always contains:
|
||||||
|
- `authBypass` (boolean, default false)
|
||||||
|
- `authHeaderName` (string, default `"X-Remote-User"`)
|
||||||
|
- Read `authBypass` and `authHeaderName` from the nested `loginOptions` in the request payload.
|
||||||
|
- Validated them (`authBypass` → bool; `authHeaderName` → non-empty string, fallback to `"X-Remote-User"`).
|
||||||
|
- Included them when building the `$configUpdate` array to pass to the model.
|
||||||
|
|
||||||
|
### src/models/AdminModel.php
|
||||||
|
|
||||||
|
- Normalized `loginOptions.authBypass` to a boolean (default false).
|
||||||
|
- Validated/truncated `loginOptions.authHeaderName` to a non-empty trimmed string (default `"X-Remote-User"`).
|
||||||
|
- JSON-encoded and encrypted the full config, now including the two new fields.
|
||||||
|
- After decrypting & decoding, normalized the loaded `loginOptions` to always include:
|
||||||
|
- `authBypass` (bool)
|
||||||
|
- `authHeaderName` (string, default `"X-Remote-User"`)
|
||||||
|
- Left all existing defaults & validations for the original flags intact.
|
||||||
|
|
||||||
|
### public/js/adminPanel.js
|
||||||
|
|
||||||
|
- **Login Options** section:
|
||||||
|
- Added a checkbox for **Disable All Built-in Logins (proxy only)** (`authBypass`).
|
||||||
|
- Added a text input for **Auth Header Name** (`authHeaderName`).
|
||||||
|
- In `handleSave()`:
|
||||||
|
- Included the new `authBypass` and `authHeaderName` values in the payload sent to `updateConfig.php`.
|
||||||
|
- In `openAdminPanel()`:
|
||||||
|
- Initialized those inputs from `config.loginOptions.authBypass` and `config.loginOptions.authHeaderName`.
|
||||||
|
|
||||||
|
### public/js/auth.js
|
||||||
|
|
||||||
|
- In `loadAdminConfigFunc()`:
|
||||||
|
- Stored `authBypass` and `authHeaderName` in `localStorage`.
|
||||||
|
- In `checkAuthentication()`:
|
||||||
|
- After a successful login check, called a new helper (`applyProxyBypassUI()`) which reads `localStorage.authBypass` and conditionally hides the entire login form/UI.
|
||||||
|
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
|
||||||
|
- No other core fetch/token logic changed; all existing flows remain intact.
|
||||||
|
|
||||||
|
### Security old
|
||||||
|
|
||||||
|
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 5/4/2025 v1.3.1
|
## Changes 5/4/2025 v1.3.1
|
||||||
|
|
||||||
### Modals
|
### Modals
|
||||||
@@ -20,10 +932,10 @@
|
|||||||
- **Inserted** inline `<style>` in `<head>` to:
|
- **Inserted** inline `<style>` in `<head>` to:
|
||||||
- Hide `.main-wrapper` by default.
|
- Hide `.main-wrapper` by default.
|
||||||
- Style `#loadingOverlay` as a full-viewport white overlay.
|
- Style `#loadingOverlay` as a full-viewport white overlay.
|
||||||
|
|
||||||
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
|
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
|
||||||
|
|
||||||
### `main.js`
|
**`main.js`**
|
||||||
|
|
||||||
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
|
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
|
||||||
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
|||||||
COPY --from=appsource /var/www /var/www
|
COPY --from=appsource /var/www /var/www
|
||||||
COPY --from=composer /app/vendor /var/www/vendor
|
COPY --from=composer /app/vendor /var/www/vendor
|
||||||
|
|
||||||
|
# ── ensure config/ is writable by www-data so sed -i can work ──
|
||||||
|
RUN mkdir -p /var/www/config \
|
||||||
|
&& chown -R www-data:www-data /var/www/config \
|
||||||
|
&& chmod 750 /var/www/config
|
||||||
|
|
||||||
# Secure permissions: code read-only, only data dirs writable
|
# Secure permissions: code read-only, only data dirs writable
|
||||||
RUN chown -R root:www-data /var/www && \
|
RUN chown -R root:www-data /var/www && \
|
||||||
find /var/www -type d -exec chmod 755 {} \; && \
|
find /var/www -type d -exec chmod 755 {} \; && \
|
||||||
|
|||||||
358
README.md
@@ -1,11 +1,31 @@
|
|||||||
# FileRise
|
# FileRise
|
||||||
|
|
||||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
[](https://github.com/error311/FileRise)
|
||||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
[](https://hub.docker.com/r/error311/filerise-docker)
|
||||||
|
[](https://github.com/error311/filerise-docker/actions/workflows/main.yml)
|
||||||
|
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
||||||
|
[](https://demo.filerise.net)
|
||||||
|
[](https://github.com/error311/FileRise/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/sponsors/error311)
|
||||||
|
[](https://ko-fi.com/error311)
|
||||||
|
|
||||||
**4/3/2025 Video demo:**
|
**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)
|
||||||
|
|
||||||
<https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e>
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
|
**10/25/2025 Video demo:**
|
||||||
|
|
||||||
|
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||||
|
|
||||||
**Dark mode:**
|
**Dark mode:**
|
||||||

|

|
||||||
@@ -14,76 +34,148 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
|||||||
|
|
||||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. No more failed transfers – FileRise will pick up where it left off if your connection drops.
|
- 🚀 **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 set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can even download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
- 🗂️ **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 Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) – convenient for sending individual files without exposing the whole app.
|
- 🗃️ **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.
|
||||||
|
|
||||||
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it head‑less from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` – see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl%C2%A0(WebDAV)) quick‑start for examples. Folder‑Only users are restricted to their personal directory, while admins and unrestricted users have full access.
|
- 🔐 **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:
|
||||||
|
|
||||||
- 📚 **API Documentation:** Fully auto‑generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
| 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). |
|
||||||
|
|
||||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal – no need to download just to see them. Edit text/code files right in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers. Great for config files or notes – tweak and save changes without leaving FileRise.
|
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
||||||
|
|
||||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using our indexed real-time search. Easily switch to Advanced Search mode to enable fuzzy matching not only across file names, tags, and uploader fields but also within the content of text files—helping you find that “important” document even if you make a typo or need to search deep within the file.
|
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.
|
||||||
|
|
||||||
- 🔒 **User Authentication & User Permissions:** Secure your portal with username/password login. Supports multiple users – create user accounts (admin UI provided) for family or team members. User permissions such as User “Folder Only” feature assigns each user a dedicated folder within the root directory, named after their username, restricting them from viewing or modifying other directories. User Read Only and Disable Upload are additional permissions. FileRise also integrates with Single Sign-On (OIDC) providers (e.g., OAuth2/OIDC for Google/Authentik/Keycloak) and offers optional TOTP two-factor auth for extra security.
|
- 🔌 **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.
|
||||||
|
|
||||||
- 🎨 **Responsive UI (Dark/Light Mode):** FileRise is mobile-friendly out of the box – manage files from your phone or tablet with a responsive layout. Choose between Dark mode or Light theme, or let it follow your system preference. The interface remembers your preferences (layout, items per page, last visited folder, etc.) for a personalized experience each time.
|
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
||||||
|
|
||||||
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, French & German—please report any translation issues you encounter.
|
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
||||||
|
|
||||||
- 🗑️ **Trash & File Recovery:** Mistakenly deleted files? No worries – deleted items go to the Trash instead of immediate removal. Admins can restore files from Trash or empty it to free space. FileRise auto-purges old trash entries (default 3 days) to keep your storage tidy.
|
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||||
|
|
||||||
- ⚙️ **Lightweight & Self‑Contained:** FileRise runs on PHP 8.1+ with no external database required – data is stored in files (users, metadata) for simplicity. It’s a single‑folder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our pre‑built image for a hassle‑free setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
|
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||||
|
|
||||||
(For a full list of features and detailed changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [changelog](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or the [releases](https://github.com/error311/FileRise/releases) pages.)
|
- 🗑️ **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
|
## Live Demo
|
||||||
|
|
||||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). *The demo is read-only for security*. Explore the interface, switch themes, preview files, and see FileRise in action!
|
[](https://demo.filerise.net)
|
||||||
|
**Demo credentials:** `demo` / `demo`
|
||||||
|
|
||||||
|
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). **The demo is read-only for security.** Explore the interface, switch themes, preview files, and see FileRise in action!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation & Setup
|
## Installation & Setup
|
||||||
|
|
||||||
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
|
||||||
|
|
||||||
### 1. Running with Docker (Recommended)
|
---
|
||||||
|
|
||||||
If you have Docker installed, you can get FileRise up and running in minutes:
|
### Environment variables
|
||||||
|
|
||||||
- **Pull the image from Docker Hub:**
|
| 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. |
|
||||||
|
|
||||||
``` bash
|
---
|
||||||
|
|
||||||
|
### 1) Running with Docker (Recommended)
|
||||||
|
|
||||||
|
#### Pull the image
|
||||||
|
|
||||||
|
```bash
|
||||||
docker pull error311/filerise-docker:latest
|
docker pull error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Run a container:**
|
#### Run a container
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name filerise \
|
||||||
-p 8080:80 \
|
-p 8080:80 \
|
||||||
-e TIMEZONE="America/New_York" \
|
-e TIMEZONE="America/New_York" \
|
||||||
|
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||||
-e SECURE="false" \
|
-e SECURE="false" \
|
||||||
|
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
||||||
|
-e PUID="1000" \
|
||||||
|
-e PGID="1000" \
|
||||||
|
-e CHOWN_ON_START="true" \
|
||||||
|
-e SCAN_ON_START="true" \
|
||||||
|
-e SHARE_URL="" \
|
||||||
-v ~/filerise/uploads:/var/www/uploads \
|
-v ~/filerise/uploads:/var/www/uploads \
|
||||||
-v ~/filerise/users:/var/www/users \
|
-v ~/filerise/users:/var/www/users \
|
||||||
-v ~/filerise/metadata:/var/www/metadata \
|
-v ~/filerise/metadata:/var/www/metadata \
|
||||||
--name filerise \
|
|
||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional – for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container.
|
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.
|
||||||
|
|
||||||
- **Using Docker Compose:**
|
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
|
||||||
|
|
||||||
``` yaml
|
**Notes**
|
||||||
version: '3'
|
|
||||||
|
- **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)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Docker Compose
|
||||||
|
|
||||||
|
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3"
|
||||||
services:
|
services:
|
||||||
filerise:
|
filerise:
|
||||||
image: error311/filerise-docker:latest
|
image: error311/filerise-docker:latest
|
||||||
@@ -91,75 +183,132 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
TIMEZONE: "UTC"
|
TIMEZONE: "UTC"
|
||||||
|
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
TOTAL_UPLOAD_SIZE: "10G"
|
||||||
SECURE: "false"
|
SECURE: "false"
|
||||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||||
|
# Ownership & indexing
|
||||||
|
PUID: "1000" # Unraid users often use 99
|
||||||
|
PGID: "1000" # Unraid users often use 100
|
||||||
|
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||||
|
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||||
|
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||||
|
SHARE_URL: ""
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/var/www/uploads
|
- ./uploads:/var/www/uploads
|
||||||
- ./users:/var/www/users
|
- ./users:/var/www/users
|
||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) – be sure to change it to a random string for security.
|
Access at `http://localhost:8080` (or your server’s IP).
|
||||||
|
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||||
|
|
||||||
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and you’re in! You can then head to the **User Management** section to add additional users if needed.
|
- “`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.”
|
||||||
|
|
||||||
### 2. Manual Installation (PHP/Apache)
|
**First-time Setup**
|
||||||
|
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
|
||||||
|
|
||||||
- **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
|
|
||||||
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
git clone https://github.com/error311/FileRise.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Place the files into your web server’s directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
|
||||||
|
|
||||||
- **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
|
||||||
|
|
||||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
mkdir -p uploads users metadata
|
|
||||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user
|
|
||||||
chmod -R 775 uploads users metadata
|
|
||||||
```
|
|
||||||
|
|
||||||
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links.
|
|
||||||
|
|
||||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
|
||||||
|
|
||||||
- `BASE_URL` – the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links.
|
|
||||||
|
|
||||||
- `TIMEZONE` and `DATE_TIME_FORMAT` – match your locale (for correct timestamps).
|
|
||||||
|
|
||||||
- `TOTAL_UPLOAD_SIZE` – max aggregate upload size (default 5G). Also adjust PHP’s `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
|
|
||||||
|
|
||||||
- `PERSISTENT_TOKENS_KEY` – set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
|
|
||||||
|
|
||||||
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally don’t need changes unless you move those folders. Defaults are set for the directories mentioned above.
|
|
||||||
|
|
||||||
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config – these disable directory listings and prevent access to certain files. For Nginx or others, you’ll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links.
|
|
||||||
|
|
||||||
Now navigate to the FileRise URL in your browser. On first load, you’ll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick‑start: Mount via WebDAV
|
### 2) Manual Installation (PHP/Apache)
|
||||||
|
|
||||||
Once FileRise is running, you must enable WebDAV in admin panel to access it.
|
If you prefer a traditional web server (LAMP stack or similar):
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Download Files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/error311/FileRise.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||||
|
|
||||||
|
**Composer (if applicable)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folders & Permissions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p uploads users metadata
|
||||||
|
chown -R www-data:www-data uploads users metadata # use your web user
|
||||||
|
chmod -R 775 uploads users metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
- `uploads/`: actual files
|
||||||
|
- `users/`: credentials & token storage
|
||||||
|
- `metadata/`: file metadata (tags, share links, etc.)
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
|
||||||
|
Edit `config.php`:
|
||||||
|
|
||||||
|
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||||
|
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
||||||
|
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
||||||
|
|
||||||
|
**Share link base URL**
|
||||||
|
|
||||||
|
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
||||||
|
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
||||||
|
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||||
|
|
||||||
|
**Web server config**
|
||||||
|
|
||||||
|
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||||
|
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||||
|
|
||||||
|
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) Admins
|
||||||
|
|
||||||
|
> **Admins in ACL UI**
|
||||||
|
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unraid
|
||||||
|
|
||||||
|
- Install from **Community Apps** → search **FileRise**.
|
||||||
|
- Default **bridge**: access at `http://SERVER_IP:8080/`.
|
||||||
|
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
|
||||||
|
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull error311/filerise-docker:latest
|
||||||
|
docker stop filerise && docker rm filerise
|
||||||
|
# re-run with the same -v and -e flags you used originally
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick-start: Mount via WebDAV
|
||||||
|
|
||||||
|
Once FileRise is running, enable WebDAV in the admin panel.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (GVFS/GIO)
|
# Linux (GVFS/GIO)
|
||||||
gio mount dav://demo@your-host/webdav.php/
|
gio mount dav://demo@your-host/webdav.php/
|
||||||
|
|
||||||
# macOS (Finder → Go → Connect to Server…)
|
# macOS (Finder → Go → Connect to Server…)
|
||||||
dav://demo@your-host/webdav.php/
|
https://your-host/webdav.php/
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
|
||||||
|
|
||||||
### Windows (File Explorer)
|
### Windows (File Explorer)
|
||||||
|
|
||||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
||||||
@@ -170,8 +319,8 @@ dav://demo@your-host/webdav.php/
|
|||||||
https://your-host/webdav.php/
|
https://your-host/webdav.php/
|
||||||
```
|
```
|
||||||
|
|
||||||
- Check **Connect using different credentials**, and enter your FileRise username and password.
|
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||||
- Click **Finish**. The drive will now appear under **This PC**.
|
- Click **Finish**.
|
||||||
|
|
||||||
> **Important:**
|
> **Important:**
|
||||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||||
@@ -186,41 +335,64 @@ dav://demo@your-host/webdav.php/
|
|||||||
>
|
>
|
||||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||||
> 4. Set its value to `2`.
|
> 4. Set its value to `2`.
|
||||||
> 5. Restart the **WebClient** service or reboot your computer.
|
> 5. Restart the **WebClient** service or reboot.
|
||||||
|
|
||||||
📖 For a full guide (including SSL setup, HTTP workaround, and troubleshooting), see the [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV).
|
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ / Troubleshooting
|
## FAQ / Troubleshooting
|
||||||
|
|
||||||
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are all set high enough. For extremely large files, you might also need to increase max_execution_time in PHP or rely on the resumable upload feature in smaller chunks.
|
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||||
|
|
||||||
- **How to enable HTTPS?** FileRise itself doesn’t handle TLS. Run it behind a reverse proxy like Nginx, Caddy, or Apache with SSL, or use Docker with a companion like nginx-proxy or Caddy. Set `SECURE="true"` env var in Docker so FileRise knows to generate https links.
|
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||||
|
|
||||||
- **Changing Admin or resetting password:** Admin can change any user’s password via the UI (User Management section). If you lose admin access, you can edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and then restart the app to trigger the setup flow again.
|
- **Changing Admin or resetting password:** Admin can change any user’s password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
|
||||||
|
|
||||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set for `UPLOAD_DIR`). Within it, files are organized in the folder structure you see in the app. Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata`.json and trash metadata in `metadata/trash.json`, etc. Regular backups of these folders is recommended if the data is important.
|
- **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:** If using Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace the files (preserve your `config.php` and the uploads/users/metadata folders). Clear your browser cache if you have issues after an update (in case CSS/JS changed).
|
- **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, please check the Discussions or open an issue.
|
For more Q&A or to ask for help, open a Discussion or Issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||||
|
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||||
|
If you’re running ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
|
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! If you have ideas for new features or have found a bug, feel free to open an issue. Check out the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. You can also join the conversation in GitHub Discussions or on Reddit (see links below) to share feedback and suggestions.
|
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!
|
||||||
|
|
||||||
Areas where you can help: translations, bug fixes, UI improvements, or building integration with other services. If you like FileRise, giving the project a ⭐ star ⭐ on GitHub is also a much-appreciated contribution!
|
---
|
||||||
|
|
||||||
|
## 💖 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
|
## Community and Support
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1jl01pi/introducing_filerise_a_modern_selfhosted_file/) – (Announcement and user feedback thread).
|
- **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.
|
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||||
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
|
||||||
|
|
||||||
|
[](https://star-history.com/#error311/FileRise&Date)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,4 +425,4 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is open-source under the MIT License. That means you’re free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!
|
MIT License – see [LICENSE](LICENSE).
|
||||||
|
|||||||
64
SECURITY.md
@@ -2,32 +2,60 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases).
|
We provide security fixes for the latest minor release line.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|----------|-----------|
|
||||||
|
| v1.5.x | ✅ |
|
||||||
|
| ≤ v1.4.x | ❌ |
|
||||||
|
|
||||||
|
> Known issues in ≤ v1.4.x are fixed in **v1.5.0** and later.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability, please do not open a public issue. Instead, follow these steps:
|
**Please do not open a public issue.** Use one of the private channels below:
|
||||||
|
|
||||||
1. **Email Us Privately:**
|
1) **GitHub Security Advisory (preferred)**
|
||||||
Send an email to [security@filerise.net](mailto:security@filerise.net) with the subject line “[FileRise] Security Vulnerability Report”.
|
Open a private report here: <https://github.com/error311/FileRise/security/advisories/new>
|
||||||
|
|
||||||
2. **Include Details:**
|
2) **Email**
|
||||||
Provide a detailed description of the vulnerability, steps to reproduce it, and any other relevant information (e.g., affected versions, screenshots, logs).
|
Send details to **<security@filerise.net>** with subject: `[FileRise] Security Vulnerability Report`.
|
||||||
|
|
||||||
3. **Secure Communication (Optional):**
|
### What to include
|
||||||
If you wish to discuss the vulnerability securely, you can use our PGP key. You can obtain our PGP key by emailing us, and we will send it upon request.
|
|
||||||
|
|
||||||
## Disclosure Policy
|
- Affected versions (e.g., v1.4.0), component/endpoint, and impact
|
||||||
|
- Reproduction steps / PoC
|
||||||
|
- Any logs, screenshots, or crash traces
|
||||||
|
- Safe test scope used (see below)
|
||||||
|
|
||||||
- **Acknowledgement:**
|
If you’d like encrypted comms, ask for our PGP key in your first email.
|
||||||
We will acknowledge receipt of your report within 48 hours.
|
|
||||||
|
|
||||||
- **Resolution Timeline:**
|
|
||||||
We aim to fix confirmed vulnerabilities within 30 days. In cases where a delay is necessary, we will communicate updates to you directly.
|
|
||||||
|
|
||||||
- **Public Disclosure:**
|
## Coordinated Disclosure
|
||||||
After a fix is available, details of the vulnerability will be disclosed publicly in a way that does not compromise user security.
|
|
||||||
|
|
||||||
## Additional Information
|
- **Acknowledgement:** within **48 hours**
|
||||||
|
- **Triage & initial assessment:** within **7 days**
|
||||||
|
- **Fix target:** within **30 days** for high-severity issues (may vary by complexity)
|
||||||
|
- **CVE & advisory:** we publish a GitHub Security Advisory and request a CVE when appropriate.
|
||||||
|
We notify the reporter before public disclosure and credit them (unless they prefer to remain anonymous).
|
||||||
|
|
||||||
We appreciate responsible disclosure of vulnerabilities and thank all researchers who help keep FileRise secure. For any questions related to this policy, please contact us at [admin@filerise.net](mailto:admin@filerise.net).
|
## Safe-Harbor / Rules of Engagement
|
||||||
|
|
||||||
|
We support good-faith research. Please:
|
||||||
|
|
||||||
|
- Avoid privacy violations, data exfiltration, and service disruption (no DoS, spam, or brute-forcing)
|
||||||
|
- Don’t access other users’ data beyond what’s necessary to demonstrate the issue
|
||||||
|
- Don’t run automated scans against production installs you don’t own
|
||||||
|
- Follow applicable laws and make a good-faith effort to respect data and availability
|
||||||
|
|
||||||
|
If you follow these guidelines, we won’t pursue or support legal action.
|
||||||
|
|
||||||
|
## Published Advisories
|
||||||
|
|
||||||
|
- **GHSA-6p87-q9rh-95wh** — ≤ **1.3.15**: Improper ownership/permission validation allowed cross-tenant file operations.
|
||||||
|
- **GHSA-jm96-2w52-5qjj** — **v1.4.0**: Insecure folder visibility via name-based mapping and incomplete ACL checks.
|
||||||
|
|
||||||
|
Both are fixed in **v1.5.0** (ACL hardening). Thanks to **[@kiwi865](https://github.com/kiwi865)** for responsible disclosure.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
General security questions: **<admin@filerise.net>**
|
||||||
|
|||||||
@@ -28,13 +28,20 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
|||||||
define('TIMEZONE', 'America/New_York');
|
define('TIMEZONE', 'America/New_York');
|
||||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||||
define('TOTAL_UPLOAD_SIZE','5G');
|
define('TOTAL_UPLOAD_SIZE','5G');
|
||||||
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
|
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||||
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u');
|
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||||
|
|
||||||
date_default_timezone_set(TIMEZONE);
|
date_default_timezone_set(TIMEZONE);
|
||||||
|
|
||||||
|
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||||
|
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||||
|
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);
|
||||||
|
|
||||||
// Encryption helpers
|
// Encryption helpers
|
||||||
function encryptData($data, $encryptionKey)
|
function encryptData($data, $encryptionKey)
|
||||||
{
|
{
|
||||||
@@ -69,16 +76,27 @@ function loadUserPermissions($username)
|
|||||||
{
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||||
if (file_exists($permissionsFile)) {
|
if (!file_exists($permissionsFile)) {
|
||||||
$content = file_get_contents($permissionsFile);
|
return false;
|
||||||
$decrypted = decryptData($content, $encryptionKey);
|
|
||||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
|
||||||
$perms = json_decode($json, true);
|
|
||||||
if (is_array($perms) && isset($perms[$username])) {
|
|
||||||
return !empty($perms[$username]) ? $perms[$username] : false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
$content = file_get_contents($permissionsFile);
|
||||||
|
$decrypted = decryptData($content, $encryptionKey);
|
||||||
|
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||||
|
$permsAll = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!is_array($permsAll)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first, then lowercase (since we store keys lowercase elsewhere)
|
||||||
|
$uExact = (string)$username;
|
||||||
|
$uLower = strtolower($uExact);
|
||||||
|
|
||||||
|
$row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null;
|
||||||
|
|
||||||
|
// Normalize: always return an array when found, else false (to preserve current callers’ behavior)
|
||||||
|
return is_array($row) ? $row : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine HTTPS usage
|
// Determine HTTPS usage
|
||||||
@@ -88,25 +106,39 @@ $secure = ($envSecure !== false)
|
|||||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
// Choose session lifetime based on "remember me" cookie
|
// Choose session lifetime based on "remember me" cookie
|
||||||
$defaultSession = 7200; // 2 hours
|
$defaultSession = 7200; // 2 hours
|
||||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||||
$sessionLifetime = isset($_COOKIE['remember_me_token'])
|
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||||
? $persistentDays
|
|
||||||
: $defaultSession;
|
|
||||||
|
|
||||||
// Configure PHP session cookie and GC
|
|
||||||
session_set_cookie_params([
|
|
||||||
'lifetime' => $sessionLifetime,
|
|
||||||
'path' => '/',
|
|
||||||
'domain' => '', // adjust if you need a specific domain
|
|
||||||
'secure' => $secure,
|
|
||||||
'httponly' => true,
|
|
||||||
'samesite' => 'Lax'
|
|
||||||
]);
|
|
||||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start session idempotently:
|
||||||
|
* - If no session: set cookie params + gc_maxlifetime, then session_start().
|
||||||
|
* - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry.
|
||||||
|
*/
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => $sessionLifetime,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '', // adjust if you need a specific domain
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax'
|
||||||
|
]);
|
||||||
|
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||||
session_start();
|
session_start();
|
||||||
|
} else {
|
||||||
|
// Optionally refresh the session cookie expiry to keep the user alive
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
if ($sessionLifetime > 0) {
|
||||||
|
setcookie(session_name(), session_id(), [
|
||||||
|
'expires' => time() + $sessionLifetime,
|
||||||
|
'path' => $params['path'] ?: '/',
|
||||||
|
'domain' => $params['domain'] ?? '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => $params['samesite'] ?? 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF token
|
// CSRF token
|
||||||
@@ -114,7 +146,7 @@ if (empty($_SESSION['csrf_token'])) {
|
|||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto‑login via persistent token
|
// Auto-login via persistent token
|
||||||
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
$tokens = [];
|
$tokens = [];
|
||||||
@@ -140,13 +172,75 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share URL fallback
|
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||||
|
|
||||||
|
// sane defaults:
|
||||||
|
$cfgAuthBypass = false;
|
||||||
|
$cfgAuthHeader = 'X_REMOTE_USER';
|
||||||
|
|
||||||
|
if (file_exists($adminConfigFile)) {
|
||||||
|
$encrypted = file_get_contents($adminConfigFile);
|
||||||
|
$decrypted = decryptData($encrypted, $encryptionKey);
|
||||||
|
$adminCfg = json_decode($decrypted, true) ?: [];
|
||||||
|
|
||||||
|
$loginOpts = $adminCfg['loginOptions'] ?? [];
|
||||||
|
|
||||||
|
// proxy-only bypass flag
|
||||||
|
$cfgAuthBypass = ! empty($loginOpts['authBypass']);
|
||||||
|
|
||||||
|
// header name (e.g. “X-Remote-User” → HTTP_X_REMOTE_USER)
|
||||||
|
$hdr = trim($loginOpts['authHeaderName'] ?? '');
|
||||||
|
if ($hdr === '') {
|
||||||
|
$hdr = 'X-Remote-User';
|
||||||
|
}
|
||||||
|
// normalize to PHP’s $_SERVER key format:
|
||||||
|
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
|
||||||
|
}
|
||||||
|
|
||||||
|
define('AUTH_BYPASS', $cfgAuthBypass);
|
||||||
|
define('AUTH_HEADER', $cfgAuthHeader);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PROXY-ONLY AUTO–LOGIN now uses those constants:
|
||||||
|
if (AUTH_BYPASS) {
|
||||||
|
$hdrKey = AUTH_HEADER; // e.g. "HTTP_X_REMOTE_USER"
|
||||||
|
if (!empty($_SERVER[$hdrKey])) {
|
||||||
|
// regenerate once per session
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $_SERVER[$hdrKey];
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
|
$_SESSION['username'] = $username;
|
||||||
|
|
||||||
|
// ◾ lookup actual role instead of forcing admin
|
||||||
|
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
|
||||||
|
$role = AuthModel::getUserRole($username);
|
||||||
|
$_SESSION['isAdmin'] = ($role === '1');
|
||||||
|
|
||||||
|
// carry over any folder/read/upload perms
|
||||||
|
$perms = loadUserPermissions($username) ?: [];
|
||||||
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share URL fallback (keep BASE_URL behavior)
|
||||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||||
|
|
||||||
|
// Detect scheme correctly (works behind proxies too)
|
||||||
|
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
|
||||||
|
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
|
||||||
|
);
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
|
||||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||||
$defaultShare = isset($_SERVER['HTTP_HOST'])
|
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||||
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
|
|
||||||
: "http://localhost/api/file/share.php";
|
|
||||||
} else {
|
} else {
|
||||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final: env var wins, else fallback
|
||||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||||
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
filerise:
|
||||||
|
# Use the published image (does NOT build in CI by default)
|
||||||
|
image: error311/filerise-docker:latest
|
||||||
|
container_name: filerise
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# If someone wants to build locally instead, they can uncomment:
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${HOST_HTTP_PORT:-8080}:80"
|
||||||
|
# Uncomment if you really terminate TLS inside the container:
|
||||||
|
# - "${HOST_HTTPS_PORT:-8443}:443"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TIMEZONE: "${TIMEZONE:-UTC}"
|
||||||
|
DATE_TIME_FORMAT: "${DATE_TIME_FORMAT:-m/d/y h:iA}"
|
||||||
|
TOTAL_UPLOAD_SIZE: "${TOTAL_UPLOAD_SIZE:-5G}"
|
||||||
|
SECURE: "${SECURE:-false}"
|
||||||
|
PERSISTENT_TOKENS_KEY: "${PERSISTENT_TOKENS_KEY:-please_change_this_@@}"
|
||||||
|
PUID: "${PUID:-1000}"
|
||||||
|
PGID: "${PGID:-1000}"
|
||||||
|
CHOWN_ON_START: "${CHOWN_ON_START:-true}"
|
||||||
|
SCAN_ON_START: "${SCAN_ON_START:-true}"
|
||||||
|
SHARE_URL: "${SHARE_URL:-}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data/uploads:/var/www/uploads
|
||||||
|
- ./data/users:/var/www/users
|
||||||
|
- ./data/metadata:/var/www/metadata
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
5098
openapi.json.dist
@@ -50,6 +50,12 @@ RewriteEngine On
|
|||||||
<FilesMatch "\.(js|css)$">
|
<FilesMatch "\.(js|css)$">
|
||||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
# version.js should always revalidate (it changes on releases)
|
||||||
|
<FilesMatch "^js/version\.js$">
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires "0"
|
||||||
|
</FilesMatch>
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ if (isset($_GET['spec'])) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>FileRise API Docs</title>
|
<title>FileRise API Docs</title>
|
||||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||||
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
|
integrity="sha384-70P5pmIdaQdVbxvjhrcTDv1uKcKqalZ3OHi7S2J+uzDl0PW8dO6L+pHOpm9EEjGJ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script defer src="/js/redoc-init.js"></script>
|
<script defer src="/js/redoc-init.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/addUser.php
|
// public/api/addUser.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/addUser.php",
|
||||||
|
* summary="Add a new user",
|
||||||
|
* description="Adds a new user to the system. In setup mode, the new user is automatically made admin.",
|
||||||
|
* operationId="addUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username", "password"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="password", type="string", example="securepassword"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User added successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User added successfully")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
85
public/api/admin/acl/getGrants.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/acl/getGrants.php
|
||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
121
public/api/admin/acl/saveGrants.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/acl/saveGrants.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.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']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
|
||||||
|
$csrf = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Invalid 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)) {
|
||||||
|
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}]}']);
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/getConfig.php
|
// public/api/admin/getConfig.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/admin/getConfig.php",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* summary="Get UI configuration",
|
||||||
|
* description="Returns a public subset for everyone; authenticated admins receive additional loginOptions fields.",
|
||||||
|
* operationId="getAdminConfig",
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Configuration loaded",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/AdminGetConfigPublic"),
|
||||||
|
* @OA\Schema(ref="#/components/schemas/AdminGetConfigAdmin")
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=500, description="Server error")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Retrieves the admin configuration settings and outputs JSON.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/readMetadata.php
|
// public/api/admin/readMetadata.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/admin/readMetadata.php",
|
||||||
|
* summary="Read share metadata JSON",
|
||||||
|
* description="Admin-only: returns the cleaned metadata for file or folder share links.",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* operationId="readMetadata",
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="file",
|
||||||
|
* in="query",
|
||||||
|
* required=true,
|
||||||
|
* description="Which metadata file to read",
|
||||||
|
* @OA\Schema(type="string", enum={"share_links.json","share_folder_links.json"})
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="OK",
|
||||||
|
* @OA\JsonContent(oneOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ShareLinksMap"),
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ShareFolderLinksMap")
|
||||||
|
* })
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing or invalid file param"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden (admin only)"),
|
||||||
|
* @OA\Response(response=500, description="Corrupted JSON")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
// Only admins may read these
|
// Only admins may read these
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/updateConfig.php
|
// public/api/admin/updateConfig.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/admin/updateConfig.php",
|
||||||
|
* summary="Update admin configuration",
|
||||||
|
* description="Merges the provided settings into the on-disk configuration and persists them. Requires an authenticated admin session and a valid CSRF token. When OIDC is enabled (disableOIDCLogin=false), `providerUrl`, `redirectUri`, and `clientId` are required and must be HTTPS (HTTP allowed only for localhost).",
|
||||||
|
* operationId="updateAdminConfig",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* security={ {{"cookieAuth": {}, "CsrfHeader": {}}} },
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/AdminUpdateConfigRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Configuration updated",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleSuccess")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Validation error (e.g., bad authHeaderName, missing OIDC fields when enabled, or negative upload limit)",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Unauthorized access or invalid CSRF token",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* // or: ref to the reusable response
|
||||||
|
* // ref="#/components/responses/Forbidden"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Server error while loading or saving configuration",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/auth.php
|
// public/api/auth/auth.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/auth/auth.php",
|
||||||
|
* summary="Authenticate user",
|
||||||
|
* description="Handles user authentication via OIDC or form-based credentials. For OIDC flows, processes callbacks; otherwise, performs standard authentication with optional TOTP verification.",
|
||||||
|
* operationId="authUser",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username", "password"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="password", type="string", example="secretpassword"),
|
||||||
|
* @OA\Property(property="remember_me", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Login successful; returns user info and status",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="success", type="string", example="Login successful"),
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request (e.g., missing credentials)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized (e.g., invalid credentials, too many attempts)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many failed login attempts"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Handles user authentication via OIDC or form-based login.
|
||||||
|
*
|
||||||
|
* @return void Redirects on success or outputs JSON error.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/checkAuth.php
|
// public/api/auth/checkAuth.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/checkAuth.php",
|
||||||
|
* summary="Check authentication status",
|
||||||
|
* operationId="checkAuth",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Authenticated status or setup flag",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="authenticated", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="folderOnly", type="boolean", example=false)
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="setup", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/login_basic.php
|
// public/api/auth/login_basic.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/login_basic.php",
|
||||||
|
* summary="Authenticate using HTTP Basic Authentication",
|
||||||
|
* description="Performs HTTP Basic authentication. If credentials are missing, sends a 401 response prompting for Basic auth. On valid credentials, optionally handles TOTP verification and finalizes session login.",
|
||||||
|
* operationId="loginBasic",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Login successful; redirects to index.html",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="string", example="Login successful")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized due to missing credentials or invalid credentials."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Handles HTTP Basic authentication (with optional TOTP) and logs the user in.
|
||||||
|
*
|
||||||
|
* @return void Redirects on success or sends a 401 header.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/logout.php
|
// public/api/auth/logout.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/auth/logout.php",
|
||||||
|
* summary="Logout user",
|
||||||
|
* description="Clears the session, removes persistent login tokens, and redirects the user to the login page.",
|
||||||
|
* operationId="logoutUser",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=302,
|
||||||
|
* description="Redirects to the login page with a logout flag."
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Logs the user out by clearing session data, removing persistent tokens, and destroying the session.
|
||||||
|
*
|
||||||
|
* @return void Redirects to index.html with a logout flag.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/token.php
|
// public/api/auth/token.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/token.php",
|
||||||
|
* summary="Retrieve CSRF token and share URL",
|
||||||
|
* description="Returns the current CSRF token along with the configured share URL.",
|
||||||
|
* operationId="getToken",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="CSRF token and share URL",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="csrf_token", type="string", example="0123456789abcdef..."),
|
||||||
|
* @OA\Property(property="share_url", type="string", example="https://yourdomain.com/share.php")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns the CSRF token and share URL.
|
||||||
|
*
|
||||||
|
* @return void Outputs the JSON response.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/changePassword.php
|
// public/api/changePassword.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/changePassword.php",
|
||||||
|
* summary="Change user password",
|
||||||
|
* description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.",
|
||||||
|
* operationId="changePassword",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"oldPassword", "newPassword", "confirmPassword"},
|
||||||
|
* @OA\Property(property="oldPassword", type="string", example="oldpass123"),
|
||||||
|
* @OA\Property(property="newPassword", type="string", example="newpass456"),
|
||||||
|
* @OA\Property(property="confirmPassword", type="string", example="newpass456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Password updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="Password updated successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/copyFiles.php
|
// public/api/file/copyFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/copyFiles.php",
|
||||||
|
* summary="Copy files between folders",
|
||||||
|
* description="Requires read access on source and write access on destination. Enforces folder scope and ownership.",
|
||||||
|
* operationId="copyFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* description="CSRF token from the current session",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"source","destination","files"},
|
||||||
|
* @OA\Property(property="source", type="string", example="root"),
|
||||||
|
* @OA\Property(property="destination", type="string", example="userA/projects"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"report.pdf","notes.txt"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Copy result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid request or folder name"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
39
public/api/file/createFile.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/createFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/createFile.php",
|
||||||
|
* summary="Create an empty file",
|
||||||
|
* description="Requires write access on the target folder. Enforces folder-only scope.",
|
||||||
|
* operationId="createFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","name"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="name", type="string", example="new.txt")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Creation result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fc = new FileController();
|
||||||
|
$fc->createFile();
|
||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/createShareLink.php
|
// public/api/file/createShareLink.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/createShareLink.php",
|
||||||
|
* summary="Create a share link for a file",
|
||||||
|
* description="Requires share permission on the folder. Non-admins must own the file unless bypassOwnership.",
|
||||||
|
* operationId="createShareLink",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","file"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="file", type="string", example="invoice.pdf"),
|
||||||
|
* @OA\Property(property="expirationValue", type="integer", example=60),
|
||||||
|
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
|
||||||
|
* @OA\Property(property="password", type="string", example="")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Share link created",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="token", type="string", example="abc123"),
|
||||||
|
* @OA\Property(property="url", type="string", example="/api/file/share.php?token=abc123"),
|
||||||
|
* @OA\Property(property="expires", type="integer", example=1700000000)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/deleteFiles.php
|
// public/api/file/deleteFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteFiles.php",
|
||||||
|
* summary="Delete files to Trash",
|
||||||
|
* description="Requires write access on the folder and (for non-admins) ownership of the files.",
|
||||||
|
* operationId="deleteFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"old.docx","draft.md"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Delete result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteShareLink.php",
|
||||||
|
* summary="Delete a share link by token",
|
||||||
|
* description="Deletes a share token. NOTE: Current implementation does not require authentication.",
|
||||||
|
* operationId="deleteShareLink",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"token"},
|
||||||
|
* @OA\Property(property="token", type="string", example="abc123")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (success or not found)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/deleteTrashFiles.php
|
// public/api/file/deleteTrashFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteTrashFiles.php",
|
||||||
|
* summary="Permanently delete Trash items (admin only)",
|
||||||
|
* operationId="deleteTrashFiles",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"deleteAll"},
|
||||||
|
* @OA\Property(property="deleteAll", type="boolean", example=true)
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"files"},
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/abc","trash/def"})
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (model-defined)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/download.php
|
// public/api/file/download.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/download.php",
|
||||||
|
* summary="Download a file",
|
||||||
|
* description="Requires view access (or own-only with ownership). Streams the file with appropriate Content-Type.",
|
||||||
|
* operationId="downloadFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||||
|
* @OA\Parameter(name="file", in="query", required=true, @OA\Schema(type="string"), example="photo.jpg"),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder/file"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/downloadZip.php
|
// public/api/file/downloadZip.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/downloadZip.php",
|
||||||
|
* summary="Download multiple files as a ZIP",
|
||||||
|
* description="Requires view access (or own-only with ownership). May be gated by account flag.",
|
||||||
|
* operationId="downloadZip",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"a.jpg","b.png"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="ZIP archive",
|
||||||
|
* content={
|
||||||
|
* "application/zip": @OA\MediaType(
|
||||||
|
* mediaType="application/zip",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/extractZip.php
|
// public/api/file/extractZip.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/extractZip.php",
|
||||||
|
* summary="Extract ZIP file(s) into a folder",
|
||||||
|
* description="Requires write access on the target folder.",
|
||||||
|
* operationId="extractZip",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"archive.zip"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Extraction result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getFileList.php
|
// public/api/file/getFileList.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getFileList.php",
|
||||||
|
* summary="List files in a folder",
|
||||||
|
* description="Requires view access (full) or read_own (own-only results).",
|
||||||
|
* operationId="getFileList",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||||
|
* @OA\Response(response=200, description="Listing result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getFileTag.php
|
// public/api/file/getFileTag.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getFileTags.php",
|
||||||
|
* summary="Get global file tags",
|
||||||
|
* description="Returns tag metadata (no auth in current implementation).",
|
||||||
|
* operationId="getFileTags",
|
||||||
|
* tags={"Tags"},
|
||||||
|
* @OA\Response(response=200, description="Tags map (model-defined JSON)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getShareLinks.php",
|
||||||
|
* summary="Get (raw) share links file",
|
||||||
|
* description="Returns the full share links JSON (no auth in current implementation).",
|
||||||
|
* operationId="getShareLinks",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\Response(response=200, description="Share links (model-defined JSON)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getTrashItems.php
|
// public/api/file/getTrashItems.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getTrashItems.php",
|
||||||
|
* summary="List items in Trash (admin only)",
|
||||||
|
* operationId="getTrashItems",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Response(response=200, description="Trash contents (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/moveFiles.php
|
// public/api/file/moveFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/moveFiles.php",
|
||||||
|
* operationId="moveFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\RequestBody(ref="#/components/requestBodies/MoveFilesRequest"),
|
||||||
|
* @OA\Response(response=200, description="Moved"),
|
||||||
|
* @OA\Response(response=400, description="Bad Request"),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||||
|
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/renameFile.php
|
// public/api/file/renameFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/file/renameFile.php",
|
||||||
|
* summary="Rename a file",
|
||||||
|
* description="Requires write access; non-admins must own the file.",
|
||||||
|
* operationId="renameFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","oldName","newName"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="oldName", type="string", example="old.pdf"),
|
||||||
|
* @OA\Property(property="newName", type="string", example="new.pdf")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Rename result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/restoreFiles.php
|
// public/api/file/restoreFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/restoreFiles.php",
|
||||||
|
* summary="Restore files from Trash (admin only)",
|
||||||
|
* operationId="restoreFiles",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"files"},
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/12345.json"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Restore result (model-defined)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/saveFile.php
|
// public/api/file/saveFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/file/saveFile.php",
|
||||||
|
* summary="Create or overwrite a file’s content",
|
||||||
|
* description="Requires write access. Overwrite enforces ownership for non-admins. Certain executable extensions are denied.",
|
||||||
|
* operationId="saveFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","fileName","content"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="fileName", type="string", example="readme.txt"),
|
||||||
|
* @OA\Property(property="content", type="string", example="Hello world")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input or disallowed extension"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/saveFileTag.php
|
// public/api/file/saveFileTag.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/saveFileTag.php",
|
||||||
|
* summary="Save tags for a file (or delete one)",
|
||||||
|
* description="Requires write access and (for non-admins) ownership when modifying.",
|
||||||
|
* operationId="saveFileTag",
|
||||||
|
* tags={"Tags"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","file"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="file", type="string", example="doc.md"),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"work","urgent"}),
|
||||||
|
* @OA\Property(property="deleteGlobal", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="tagToDelete", type="string", nullable=true, example=null)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/share.php
|
// public/api/file/share.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/share.php",
|
||||||
|
* summary="Open a shared file by token",
|
||||||
|
* description="If the link is password-protected and no password is supplied, an HTML password form is returned. Otherwise the file is streamed.",
|
||||||
|
* operationId="shareFile",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="pass", in="query", required=false, @OA\Schema(type="string")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file (or HTML password form when missing password)",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* ),
|
||||||
|
* "text/html": @OA\MediaType(mediaType="text/html")
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing token / invalid input"),
|
||||||
|
* @OA\Response(response=403, description="Expired or invalid password"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
245
public/api/folder/capabilities.php
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?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();
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
if ($username === '') {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/createFolder.php
|
// public/api/folder/createFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/createFolder.php",
|
||||||
|
* summary="Create a new folder",
|
||||||
|
* description="Requires authentication, CSRF token, and write access to the parent folder. Seeds ACL owner.",
|
||||||
|
* operationId="createFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* description="CSRF token from the current session",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folderName"},
|
||||||
|
* @OA\Property(property="folderName", type="string", example="reports"),
|
||||||
|
* @OA\Property(property="parent", type="string", nullable=true, example="root",
|
||||||
|
* description="Parent folder (default root)")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Creation result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/createShareFolderLink.php
|
// public/api/folder/createShareFolderLink.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/createShareFolderLink.php",
|
||||||
|
* summary="Create a share link for a folder",
|
||||||
|
* description="Requires authentication, CSRF token, and share permission. Non-admins must own the folder (unless bypass) and cannot share root.",
|
||||||
|
* operationId="createShareFolderLink",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="team/reports"),
|
||||||
|
* @OA\Property(property="expirationValue", type="integer", example=60),
|
||||||
|
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
|
||||||
|
* @OA\Property(property="password", type="string", example=""),
|
||||||
|
* @OA\Property(property="allowUpload", type="integer", enum={0,1}, example=0)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Share folder link created",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="token", type="string", example="sf_abc123"),
|
||||||
|
* @OA\Property(property="url", type="string", example="/api/folder/shareFolder.php?token=sf_abc123"),
|
||||||
|
* @OA\Property(property="expires", type="integer", example=1700000000)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/deleteFolder.php
|
// public/api/folder/deleteFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/deleteFolder.php",
|
||||||
|
* summary="Delete a folder",
|
||||||
|
* description="Requires authentication, CSRF token, write scope, and (for non-admins) folder ownership.",
|
||||||
|
* operationId="deleteFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="userA/reports")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/deleteShareFolderLink.php",
|
||||||
|
* summary="Delete a shared-folder link by token (admin only)",
|
||||||
|
* description="Requires authentication, CSRF token, and admin privileges.",
|
||||||
|
* operationId="deleteShareFolderLink",
|
||||||
|
* tags={"Shared Folders","Admin"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"token"},
|
||||||
|
* @OA\Property(property="token", type="string", example="sf_abc123")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deleted"),
|
||||||
|
* @OA\Response(response=400, description="No token provided"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/downloadSharedFile.php
|
// public/api/folder/downloadSharedFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/downloadSharedFile.php",
|
||||||
|
* summary="Download a file from a shared folder (by token)",
|
||||||
|
* description="Public endpoint; validates token and file name, then streams the file.",
|
||||||
|
* operationId="downloadSharedFile",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="file", in="query", required=true, @OA\Schema(type="string"), example="report.pdf"),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/getFolderList.php
|
// public/api/folder/getFolderList.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/getFolderList.php",
|
||||||
|
* summary="List folders (optionally under a parent)",
|
||||||
|
* description="Requires authentication. Non-admins see folders for which they have full view or own-only access.",
|
||||||
|
* operationId="getFolderList",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="folder", in="query", required=false,
|
||||||
|
* description="Parent folder to include and descend (default all); use 'root' for top-level",
|
||||||
|
* @OA\Schema(type="string"), example="root"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="List of folders",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="folder", type="string", example="team/reports"),
|
||||||
|
* @OA\Property(property="fileCount", type="integer", example=12),
|
||||||
|
* @OA\Property(property="metadataFile", type="string", example="/path/to/meta.json")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/getShareFolderLinks.php",
|
||||||
|
* summary="List active shared-folder links (admin only)",
|
||||||
|
* description="Returns all non-expired shared-folder links. Admin-only.",
|
||||||
|
* operationId="getShareFolderLinks",
|
||||||
|
* tags={"Shared Folders","Admin"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Response(response=200, description="Active share-folder links (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
9
public/api/folder/moveFolder.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/folder/moveFolder.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
$controller = new FolderController();
|
||||||
|
$controller->moveFolder();
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/renameFolder.php
|
// public/api/folder/renameFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/renameFolder.php",
|
||||||
|
* summary="Rename or move a folder",
|
||||||
|
* description="Requires authentication, CSRF token, scope checks on old and new paths, and (for non-admins) ownership of the source folder.",
|
||||||
|
* operationId="renameFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"oldFolder","newFolder"},
|
||||||
|
* @OA\Property(property="oldFolder", type="string", example="team/q1"),
|
||||||
|
* @OA\Property(property="newFolder", type="string", example="team/quarter-1")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Rename result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/shareFolder.php
|
// public/api/folder/shareFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/shareFolder.php",
|
||||||
|
* summary="Open a shared folder by token (HTML UI)",
|
||||||
|
* description="If the share is password-protected and no password is supplied, an HTML password form is returned. Otherwise renders an HTML listing with optional upload form.",
|
||||||
|
* operationId="shareFolder",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="pass", in="query", required=false, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", minimum=1), example=1),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="HTML page (password form or folder listing)",
|
||||||
|
* content={"text/html": @OA\MediaType(mediaType="text/html")}
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing/invalid token"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden or wrong password")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/uploadToSharedFolder.php
|
// public/api/folder/uploadToSharedFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/uploadToSharedFolder.php",
|
||||||
|
* summary="Upload a file into a shared folder (by token)",
|
||||||
|
* description="Public form-upload endpoint. Only allowed when the share link has uploads enabled. On success responds with a redirect to the share page.",
|
||||||
|
* operationId="uploadToSharedFolder",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* content={
|
||||||
|
* "multipart/form-data": @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* required={"token","fileToUpload"},
|
||||||
|
* @OA\Property(property="token", type="string", description="Share token"),
|
||||||
|
* @OA\Property(property="fileToUpload", type="string", format="binary", description="File to upload")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=302, description="Redirect to /api/folder/shareFolder.php?token=..."),
|
||||||
|
* @OA\Response(response=400, description="Upload error or invalid input"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/getUserPermissions.php
|
// public/api/getUserPermissions.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/getUserPermissions.php",
|
||||||
|
* summary="Retrieve user permissions",
|
||||||
|
* description="Returns the permissions for the current user, or all permissions if the user is an admin.",
|
||||||
|
* operationId="getUserPermissions",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Successful response with user permissions",
|
||||||
|
* @OA\JsonContent(type="object")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/getUsers.php
|
// public/api/getUsers.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/getUsers.php",
|
||||||
|
* summary="Retrieve a list of users",
|
||||||
|
* description="Returns a JSON array of users. Only available to authenticated admin users.",
|
||||||
|
* operationId="getUsers",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Successful response with an array of users",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="role", type="string", example="admin")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized: the user is not authenticated or is not an admin"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
40
public/api/profile/getCurrentUser.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/profile/getCurrentUser.php",
|
||||||
|
* operationId="getCurrentUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Current user",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"username","isAdmin","totp_enabled","profile_picture"},
|
||||||
|
* @OA\Property(property="username", type="string", example="ryan"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean"),
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean"),
|
||||||
|
* @OA\Property(property="profile_picture", type="string", example="/uploads/profile_pics/ryan.png")
|
||||||
|
* // If you had an array: @OA\Property(property="roles", type="array", @OA\Items(type="string"))
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $_SESSION['username'];
|
||||||
|
$data = UserModel::getUser($user);
|
||||||
|
echo json_encode($data);
|
||||||
68
public/api/profile/uploadPicture.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/profile/uploadPicture.php",
|
||||||
|
* summary="Upload or replace the current user's profile picture",
|
||||||
|
* description="Accepts a single image file (JPEG, PNG, or GIF) up to 2 MB. Requires a valid session cookie and CSRF token.",
|
||||||
|
* operationId="uploadProfilePicture",
|
||||||
|
* tags={"Users"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token",
|
||||||
|
* in="header",
|
||||||
|
* required=true,
|
||||||
|
* description="Anti-CSRF token associated with the current session.",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"profile_picture"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="profile_picture",
|
||||||
|
* type="string",
|
||||||
|
* format="binary",
|
||||||
|
* description="JPEG, PNG, or GIF image. Max size: 2 MB."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Profile picture updated.",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"success","url"},
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="url", type="string", example="/uploads/profile_pics/alice_9f3c2e1a8bcd.png")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="No file uploaded, invalid file type, or file too large."),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||||
|
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Server error while saving the picture.")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Always JSON, even on PHP notices
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userController = new UserController();
|
||||||
|
$userController->uploadPicture();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/removeUser.php
|
// public/api/removeUser.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/removeUser.php",
|
||||||
|
* summary="Remove a user",
|
||||||
|
* description="Removes the specified user from the system. Cannot remove the currently logged-in user.",
|
||||||
|
* operationId="removeUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User removed successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User removed successfully")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_disable.php
|
// public/api/totp_disable.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/totp_disable.php",
|
||||||
|
* summary="Disable TOTP for the authenticated user",
|
||||||
|
* description="Clears the TOTP secret from the users file for the current user.",
|
||||||
|
* operationId="disableTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="TOTP disabled successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="TOTP disabled successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Not authenticated or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Failed to disable TOTP"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_recover.php
|
// public/api/totp_recover.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_recover.php",
|
||||||
|
* summary="Recover TOTP",
|
||||||
|
* description="Verifies a recovery code to disable TOTP and finalize login.",
|
||||||
|
* operationId="recoverTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"recovery_code"},
|
||||||
|
* @OA\Property(property="recovery_code", type="string", example="ABC123DEF456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Recovery successful",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Invalid input or recovery code"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=405,
|
||||||
|
* description="Method not allowed"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many attempts"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_saveCode.php
|
// public/api/totp_saveCode.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_saveCode.php",
|
||||||
|
* summary="Generate and save a new TOTP recovery code",
|
||||||
|
* description="Generates a new TOTP recovery code for the authenticated user, stores its hash, and returns the plain text recovery code.",
|
||||||
|
* operationId="totpSaveCode",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Recovery code generated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="recoveryCode", type="string", example="ABC123DEF456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token or unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=405,
|
||||||
|
* description="Method not allowed"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_setup.php
|
// public/api/totp_setup.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/totp_setup.php",
|
||||||
|
* summary="Set up TOTP and generate a QR code",
|
||||||
|
* description="Generates (or retrieves) the TOTP secret for the user and builds a QR code image for scanning.",
|
||||||
|
* operationId="setupTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="QR code image for TOTP setup",
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="image/png"
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Unauthorized or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Server error"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_verify.php
|
// public/api/totp_verify.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_verify.php",
|
||||||
|
* summary="Verify TOTP code",
|
||||||
|
* description="Verifies a TOTP code and completes login for pending users or validates TOTP for setup verification.",
|
||||||
|
* operationId="verifyTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"totp_code"},
|
||||||
|
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="TOTP successfully verified",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="message", type="string", example="Login successful")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request (e.g., invalid input)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Not authenticated or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many attempts. Try again later."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/updateUserPanel.php
|
// public/api/updateUserPanel.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/updateUserPanel.php",
|
||||||
|
* summary="Update user panel settings",
|
||||||
|
* description="Updates user panel settings by disabling TOTP when not enabled. Accessible to authenticated users.",
|
||||||
|
* operationId="updateUserPanel",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"totp_enabled"},
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User panel updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User panel updated: TOTP disabled")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/updateUserPermissions.php
|
// public/api/updateUserPermissions.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/updateUserPermissions.php",
|
||||||
|
* summary="Update user permissions",
|
||||||
|
* description="Updates permissions for users. Only available to authenticated admin users.",
|
||||||
|
* operationId="updateUserPermissions",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"permissions"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="permissions",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="folderOnly", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="readOnly", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User permissions updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User permissions updated successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/upload/removeChunks.php
|
// public/api/upload/removeChunks.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/upload/removeChunks.php",
|
||||||
|
* summary="Remove temporary chunk directory",
|
||||||
|
* description="Deletes the temporary directory used for a chunked upload. Requires a valid CSRF token in the form field.",
|
||||||
|
* operationId="removeChunks",
|
||||||
|
* tags={"Uploads"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="resumable_myupload123"),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", description="CSRF token for this session")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Removal result",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="Temporary folder removed.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=403, description="Invalid CSRF token")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,84 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/upload/upload.php
|
// public/api/upload/upload.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/upload/upload.php",
|
||||||
|
* summary="Upload a file (supports chunked + full uploads)",
|
||||||
|
* description="Requires a session (cookie) and a CSRF token (header preferred; falls back to form field). Checks user/account flags and folder-level WRITE ACL, then delegates to the model. Returns JSON for chunked uploads; full uploads may redirect after success.",
|
||||||
|
* operationId="handleUpload",
|
||||||
|
* tags={"Uploads"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=false,
|
||||||
|
* description="CSRF token for this session (preferred). If omitted, send as form field `csrf_token`.",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* content={
|
||||||
|
* "multipart/form-data": @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* required={"fileToUpload"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="fileToUpload", type="string", format="binary",
|
||||||
|
* description="File or chunk payload."
|
||||||
|
* ),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="folder", type="string", example="root",
|
||||||
|
* description="Target folder (defaults to 'root' if omitted)."
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", description="CSRF token (form fallback)."),
|
||||||
|
* @OA\Property(property="upload_token", type="string", description="Legacy alias for CSRF token (accepted by server)."),
|
||||||
|
* @OA\Property(property="resumableChunkNumber", type="integer"),
|
||||||
|
* @OA\Property(property="resumableTotalChunks", type="integer"),
|
||||||
|
* @OA\Property(property="resumableChunkSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableCurrentChunkSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableTotalSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableType", type="string"),
|
||||||
|
* @OA\Property(property="resumableIdentifier", type="string"),
|
||||||
|
* @OA\Property(property="resumableFilename", type="string"),
|
||||||
|
* @OA\Property(property="resumableRelativePath", type="string")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="JSON result (success, chunk status, or CSRF refresh).",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema( ; Success (full or model-returned)
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="string", example="File uploaded successfully"),
|
||||||
|
* @OA\Property(property="newFilename", type="string", example="5f2d7c123a_example.png")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema( ; Chunk flow
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="status", type="string", example="chunk uploaded")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema( ; CSRF soft-refresh path
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="csrf_expired", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", example="b1c2...f9")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=302,
|
||||||
|
* description="Redirect after a successful full upload.",
|
||||||
|
* @OA\Header(header="Location", description="Where the client is redirected", @OA\Schema(type="string"))
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Bad request (missing/invalid fields, model error)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized (no session)"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden (upload disabled or no WRITE to folder)"),
|
||||||
|
* @OA\Response(response=500, description="Server error while processing upload")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||||
|
|
||||||
|
|||||||
BIN
public/assets/default-avatar.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -134,17 +134,27 @@ body.dark-mode header {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-buttons button:not(#userDropdownToggle) {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userDropdownToggle {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.header-buttons button:hover {
|
.header-buttons button:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -838,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
|||||||
background-color: #00796B;
|
background-color: #00796B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#createBtn {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dropdown-menu {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
border-color: #444 !important;
|
||||||
|
color: #e0e0e0!important;
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-menu .dropdown-item {
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-item:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
#fileList button.edit-btn {
|
#fileList button.edit-btn {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -955,6 +986,29 @@ body.dark-mode #fileList table tr {
|
|||||||
padding: 8px 10px !important;
|
padding: 8px 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--file-row-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody tr {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: var(--file-row-height) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td:not(.file-name-cell) {
|
||||||
|
height: var(--file-row-height) !important;
|
||||||
|
line-height: var(--file-row-height) !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td.file-name-cell {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2em !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
HEADINGS & FORM LABELS
|
HEADINGS & FORM LABELS
|
||||||
@@ -992,11 +1046,6 @@ label {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createFolderBtn {
|
|
||||||
margin-top: 0px !important;
|
|
||||||
height: 40px !important;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-actions {
|
.folder-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1004,6 +1053,7 @@ label {
|
|||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) and (max-width: 992px) {
|
@media (min-width: 600px) and (max-width: 992px) {
|
||||||
@@ -1012,6 +1062,70 @@ label {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn + .btn {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.folder-actions .material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn:hover,
|
||||||
|
.folder-actions .btn:focus-visible {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn:hover .material-icons,
|
||||||
|
.folder-actions .btn:focus-visible .material-icons {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .btn:focus-visible {
|
||||||
|
outline: 2px solid rgba(33,150,243,0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.folder-actions .btn,
|
||||||
|
.folder-actions .material-icons {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#moveFolderBtn {
|
||||||
|
background-color: #ff9800;
|
||||||
|
border-color: #ff9800;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.row-selected {
|
.row-selected {
|
||||||
background-color: #f2f2f2 !important;
|
background-color: #f2f2f2 !important;
|
||||||
}
|
}
|
||||||
@@ -1328,26 +1442,6 @@ body.dark-mode .image-preview-modal-content {
|
|||||||
border-color: #444;
|
border-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn,
|
|
||||||
.download-btn,
|
|
||||||
.rename-btn,
|
|
||||||
.share-btn,
|
|
||||||
.edit-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 0px;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -2002,10 +2096,9 @@ body.dark-mode .admin-panel-content label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#openChangePasswordModalBtn {
|
#openChangePasswordModalBtn {
|
||||||
width: auto;
|
width: max-content;
|
||||||
padding: 5px 10px;
|
padding: 6px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-right: 300px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#changePasswordModal {
|
#changePasswordModal {
|
||||||
@@ -2102,13 +2195,23 @@ body.dark-mode .header-drop-zone.drag-active {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
#fileSummary {
|
#fileSummary,
|
||||||
float: none !important;
|
#rowHeightSliderContainer,
|
||||||
margin: 0 auto !important;
|
#viewSliderContainer {
|
||||||
text-align: center !important;
|
float: none !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
text-align: center !important;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#viewSliderContainer label,
|
||||||
|
#viewSliderContainer span {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode #fileSummary {
|
body.dark-mode #fileSummary {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -2165,4 +2268,171 @@ body.dark-mode #searchIcon .material-icons {
|
|||||||
body.dark-mode .btn-icon:hover,
|
body.dark-mode .btn-icon:hover,
|
||||||
body.dark-mode .btn-icon:focus {
|
body.dark-mode .btn-icon:focus {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--bs-body-bg, #fff);
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu .item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-dropdown .user-menu .item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-caret {
|
||||||
|
border-top: 5px solid currentColor;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu {
|
||||||
|
background: #2c2c2c;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-username {
|
||||||
|
margin: 0 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 80px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-name {
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 80px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { --perm-caret: #444; } /* light */
|
||||||
|
body.dark-mode { --perm-caret: #ccc; } /* dark */
|
||||||
|
|
||||||
|
#zonesToggleFloating,
|
||||||
|
#sidebarToggleFloating {
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(.2,.0,.2,1),
|
||||||
|
box-shadow 160ms cubic-bezier(.2,.0,.2,1),
|
||||||
|
border-color 160ms cubic-bezier(.2,.0,.2,1),
|
||||||
|
background-color 160ms cubic-bezier(.2,.0,.2,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { --toggle-icon-color: #333; }
|
||||||
|
body.dark-mode { --toggle-icon-color: #eee; }
|
||||||
|
|
||||||
|
#zonesToggleFloating .material-icons,
|
||||||
|
#zonesToggleFloating .material-icons-outlined,
|
||||||
|
#sidebarToggleFloating .material-icons,
|
||||||
|
#sidebarToggleFloating .material-icons-outlined {
|
||||||
|
color: var(--toggle-icon-color);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating:hover,
|
||||||
|
#sidebarToggleFloating:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,.14);
|
||||||
|
border-color: #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating:active,
|
||||||
|
#sidebarToggleFloating:active {
|
||||||
|
transform: translateY(0) scale(.96);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating:focus-visible,
|
||||||
|
#sidebarToggleFloating:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0,0,0,.14),
|
||||||
|
0 0 0 3px rgba(25,118,210,.25); /* soft brandy ring */
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating::after,
|
||||||
|
#sidebarToggleFloating::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: radial-gradient(circle, rgba(0,0,0,.12) 0%, rgba(0,0,0,0) 60%);
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 300ms ease, opacity 450ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating:active::after,
|
||||||
|
#sidebarToggleFloating:active::after {
|
||||||
|
transform: scale(1.4);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zonesToggleFloating.is-collapsed,
|
||||||
|
#sidebarToggleFloating.is-collapsed {
|
||||||
|
background: #fafafa;
|
||||||
|
border-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title data-i18n-key="title">FileRise</title>
|
<title>FileRise</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
<meta name="csrf-token" content="">
|
<meta name="csrf-token" content="">
|
||||||
<meta name="share-url" content="">
|
<meta name="share-url" content="">
|
||||||
<style>
|
<style>
|
||||||
/* hide the app shell until JS says otherwise */
|
/* hide the app shell until JS says otherwise */
|
||||||
.main-wrapper { display: none; }
|
.main-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* full-screen white overlay while we check auth */
|
/* full-screen white overlay while we check auth */
|
||||||
#loadingOverlay {
|
#loadingOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
background: var(--bg-color,#fff);
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-color, #fff);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,9 +140,6 @@
|
|||||||
<!-- Your header drop zone -->
|
<!-- Your header drop zone -->
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="logoutBtn" data-i18n-title="logout">
|
|
||||||
<i class="material-icons">exit_to_app</i>
|
|
||||||
</button>
|
|
||||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||||
<i class="material-icons">vpn_key</i>
|
<i class="material-icons">vpn_key</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -284,9 +286,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
|
||||||
|
<i class="material-icons">drive_file_move</i>
|
||||||
|
</button>
|
||||||
|
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
|
||||||
|
<div id="moveFolderModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
|
||||||
|
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
|
||||||
|
into:</p>
|
||||||
|
<select id="moveFolderTarget" class="form-control modal-input"></select>
|
||||||
|
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||||
|
<button id="cancelMoveFolder" class="btn btn-secondary"
|
||||||
|
data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="renameFolderModal" class="modal">
|
<div id="renameFolderModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||||
@@ -387,8 +407,49 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||||
data-i18n-key="download_zip">Download ZIP</button>
|
data-i18n-key="download_zip">Download ZIP</button>
|
||||||
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip"
|
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
|
||||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||||
|
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||||
|
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create">
|
||||||
|
${t('create')} <span class="material-icons"
|
||||||
|
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
|
</button>
|
||||||
|
<ul id="createMenu" class="dropdown-menu" style="
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 140px;
|
||||||
|
">
|
||||||
|
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
|
||||||
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_file')}
|
||||||
|
</li>
|
||||||
|
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
|
||||||
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_folder')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Create File Modal -->
|
||||||
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||||
|
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
|
||||||
|
data-i18n-placeholder="newfile_placeholder" />
|
||||||
|
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||||
|
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
||||||
@@ -443,8 +504,7 @@
|
|||||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||||
<span id="closeChangePasswordModal"
|
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||||
class="editor-close-btn">×</span>
|
|
||||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||||
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||||
@@ -462,15 +522,15 @@
|
|||||||
<form id="addUserForm">
|
<form id="addUserForm">
|
||||||
<label for="newUsername" data-i18n-key="username">Username:</label>
|
<label for="newUsername" data-i18n-key="username">Username:</label>
|
||||||
<input type="text" id="newUsername" class="form-control" required />
|
<input type="text" id="newUsername" class="form-control" required />
|
||||||
|
|
||||||
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
||||||
<input type="password" id="addUserPassword" class="form-control" required />
|
<input type="password" id="addUserPassword" class="form-control" required />
|
||||||
|
|
||||||
<div id="adminCheckboxContainer">
|
<div id="adminCheckboxContainer">
|
||||||
<input type="checkbox" id="isAdmin" />
|
<input type="checkbox" id="isAdmin" />
|
||||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<!-- Cancel stays type="button" -->
|
<!-- Cancel stays type="button" -->
|
||||||
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
||||||
@@ -515,6 +575,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="js/version.js"></script>
|
||||||
<script type="module" src="js/main.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ import {
|
|||||||
openUserPanel,
|
openUserPanel,
|
||||||
openTOTPModal,
|
openTOTPModal,
|
||||||
closeTOTPModal,
|
closeTOTPModal,
|
||||||
setLastLoginData
|
setLastLoginData,
|
||||||
|
openApiModal
|
||||||
} from './authModals.js';
|
} from './authModals.js';
|
||||||
import { openAdminPanel } from './adminPanel.js';
|
import { openAdminPanel } from './adminPanel.js';
|
||||||
import { initializeApp } from './main.js';
|
import { initializeApp, triggerLogout } from './main.js';
|
||||||
|
|
||||||
// Production OIDC configuration (override via API as needed)
|
// Production OIDC configuration (override via API as needed)
|
||||||
const currentOIDCConfig = {
|
const currentOIDCConfig = {
|
||||||
providerUrl: "https://your-oidc-provider.com",
|
providerUrl: "https://your-oidc-provider.com",
|
||||||
clientId: "YOUR_CLIENT_ID",
|
clientId: "",
|
||||||
clientSecret: "YOUR_CLIENT_SECRET",
|
clientSecret: "",
|
||||||
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
|
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
|
||||||
globalOtpauthUrl: ""
|
globalOtpauthUrl: ""
|
||||||
};
|
};
|
||||||
@@ -35,13 +36,33 @@ window.currentOIDCConfig = currentOIDCConfig;
|
|||||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||||
|
|
||||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||||
function showToast(msgKey) {
|
|
||||||
const msg = t(msgKey);
|
function showToast(msgKeyOrText, type) {
|
||||||
if (window.pendingTOTP && msgKey === "please_log_in_to_continue") {
|
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
||||||
|
|
||||||
|
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
||||||
|
if (isDemoHost) {
|
||||||
|
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don’t nag during pending TOTP, as you already had
|
||||||
|
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
originalShowToast(msg);
|
|
||||||
|
// Translate if a key; otherwise pass through the raw text
|
||||||
|
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() isn’t available here, just use the original */ }
|
||||||
|
|
||||||
|
return originalShowToast(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showToast = showToast;
|
window.showToast = showToast;
|
||||||
|
|
||||||
const originalFetch = window.fetch;
|
const originalFetch = window.fetch;
|
||||||
@@ -125,6 +146,13 @@ function updateItemsPerPageSelect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyProxyBypassUI() {
|
||||||
|
const bypass = localStorage.getItem("authBypass") === "true";
|
||||||
|
const loginContainer = document.getElementById("loginForm");
|
||||||
|
if (loginContainer) {
|
||||||
|
loginContainer.style.display = bypass ? "none" : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
|
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
|
||||||
const authForm = document.getElementById("authForm");
|
const authForm = document.getElementById("authForm");
|
||||||
@@ -146,31 +174,38 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
updateLoginOptionsUI({
|
updateLoginOptionsUI({
|
||||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
|
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
||||||
|
authBypass: localStorage.getItem("authBypass") === "true"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAdminConfigFunc() {
|
export function loadAdminConfigFunc() {
|
||||||
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||||
.then(response => response.json())
|
.then(async (response) => {
|
||||||
.then(config => {
|
// If a proxy or some edge returns 204/empty, handle gracefully
|
||||||
localStorage.setItem("headerTitle", config.header_title || "FileRise");
|
let config = {};
|
||||||
|
try { config = await response.json(); } catch { config = {}; }
|
||||||
|
|
||||||
// Update login options using the nested loginOptions object.
|
const headerTitle = config.header_title || "FileRise";
|
||||||
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
|
localStorage.setItem("headerTitle", headerTitle);
|
||||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
|
||||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
document.title = headerTitle;
|
||||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
const lo = config.loginOptions || {};
|
||||||
|
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||||
|
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||||
|
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||||
|
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||||
|
// These may be absent for non-admins; default them
|
||||||
|
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||||
|
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||||
|
|
||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
|
||||||
const headerTitleElem = document.querySelector(".header-title h1");
|
const headerTitleElem = document.querySelector(".header-title h1");
|
||||||
if (headerTitleElem) {
|
if (headerTitleElem) headerTitleElem.textContent = headerTitle;
|
||||||
headerTitleElem.textContent = config.header_title || "FileRise";
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Use defaults.
|
// Fallback defaults if request truly fails
|
||||||
localStorage.setItem("headerTitle", "FileRise");
|
localStorage.setItem("headerTitle", "FileRise");
|
||||||
localStorage.setItem("disableFormLogin", "false");
|
localStorage.setItem("disableFormLogin", "false");
|
||||||
localStorage.setItem("disableBasicAuth", "false");
|
localStorage.setItem("disableBasicAuth", "false");
|
||||||
@@ -179,9 +214,7 @@ export function loadAdminConfigFunc() {
|
|||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
|
||||||
const headerTitleElem = document.querySelector(".header-title h1");
|
const headerTitleElem = document.querySelector(".header-title h1");
|
||||||
if (headerTitleElem) {
|
if (headerTitleElem) headerTitleElem.textContent = "FileRise";
|
||||||
headerTitleElem.textContent = "FileRise";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,21 +222,48 @@ function insertAfter(newNode, referenceNode) {
|
|||||||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuthenticatedUI(data) {
|
async function fetchProfilePicture() {
|
||||||
document.getElementById('loadingOverlay').remove();
|
try {
|
||||||
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const info = await res.json();
|
||||||
|
let pic = info.profile_picture || '';
|
||||||
|
// --- take only what's after the *last* colon ---
|
||||||
|
const parts = pic.split(':');
|
||||||
|
pic = parts[parts.length - 1] || '';
|
||||||
|
// strip any stray leading colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure exactly one leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchProfilePicture failed:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
export async function updateAuthenticatedUI(data) {
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
// Save latest auth data for later reuse
|
||||||
document.getElementById('loginForm').style.display = 'none';
|
window.__lastAuthData = data;
|
||||||
|
|
||||||
|
// 1) Remove loading overlay safely
|
||||||
|
const loading = document.getElementById('loadingOverlay');
|
||||||
|
if (loading) loading.remove();
|
||||||
|
|
||||||
|
// 2) Show main UI
|
||||||
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
toggleVisibility("loginForm", false);
|
toggleVisibility("loginForm", false);
|
||||||
toggleVisibility("mainOperations", true);
|
toggleVisibility("mainOperations", true);
|
||||||
toggleVisibility("uploadFileForm", true);
|
toggleVisibility("uploadFileForm", true);
|
||||||
toggleVisibility("fileListContainer", true);
|
toggleVisibility("fileListContainer", true);
|
||||||
//attachEnterKeyListener("addUserModal", "saveUserBtn");
|
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
|
||||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||||
|
|
||||||
|
// 3) Persist auth flags (unchanged)
|
||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
@@ -211,64 +271,156 @@ function updateAuthenticatedUI(data) {
|
|||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
}
|
}
|
||||||
if (typeof data.folderOnly !== "undefined") {
|
if (typeof data.folderOnly !== "undefined") {
|
||||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||||
|
const profilePicUrl = await fetchProfilePicture();
|
||||||
|
localStorage.setItem("profilePicUrl", profilePicUrl);
|
||||||
|
|
||||||
|
// 5) Build / update header buttons
|
||||||
const headerButtons = document.querySelector(".header-buttons");
|
const headerButtons = document.querySelector(".header-buttons");
|
||||||
const firstButton = headerButtons.firstElementChild;
|
const firstButton = headerButtons.firstElementChild;
|
||||||
|
|
||||||
|
// a) restore-from-trash for admins
|
||||||
if (data.isAdmin) {
|
if (data.isAdmin) {
|
||||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
let r = document.getElementById("restoreFilesBtn");
|
||||||
if (!restoreBtn) {
|
if (!r) {
|
||||||
restoreBtn = document.createElement("button");
|
r = document.createElement("button");
|
||||||
restoreBtn.id = "restoreFilesBtn";
|
r.id = "restoreFilesBtn";
|
||||||
restoreBtn.classList.add("btn", "btn-warning");
|
r.classList.add("btn","btn-warning");
|
||||||
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
|
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||||
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||||
if (firstButton) insertAfter(restoreBtn, firstButton);
|
if (firstButton) insertAfter(r, firstButton);
|
||||||
else headerButtons.appendChild(restoreBtn);
|
else headerButtons.appendChild(r);
|
||||||
}
|
|
||||||
restoreBtn.style.display = "block";
|
|
||||||
|
|
||||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (!adminPanelBtn) {
|
|
||||||
adminPanelBtn = document.createElement("button");
|
|
||||||
adminPanelBtn.id = "adminPanelBtn";
|
|
||||||
adminPanelBtn.classList.add("btn", "btn-info");
|
|
||||||
adminPanelBtn.setAttribute("data-i18n-title", "admin_panel");
|
|
||||||
adminPanelBtn.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(adminPanelBtn, restoreBtn);
|
|
||||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
|
||||||
} else {
|
|
||||||
adminPanelBtn.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
r.style.display = "block";
|
||||||
} else {
|
} else {
|
||||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
const r = document.getElementById("restoreFilesBtn");
|
||||||
if (restoreBtn) restoreBtn.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
// b) admin panel button only on demo.filerise.net
|
||||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||||
if (!userPanelBtn) {
|
let a = document.getElementById("adminPanelBtn");
|
||||||
userPanelBtn = document.createElement("button");
|
if (!a) {
|
||||||
userPanelBtn.id = "userPanelBtn";
|
a = document.createElement("button");
|
||||||
userPanelBtn.classList.add("btn", "btn-user");
|
a.id = "adminPanelBtn";
|
||||||
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
|
a.classList.add("btn","btn-info");
|
||||||
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
|
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
|
||||||
|
const avatarHTML = profilePicUrl
|
||||||
|
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
|
||||||
|
: `<i class="material-icons">account_circle</i>`;
|
||||||
|
|
||||||
|
// fallback username if missing
|
||||||
|
const usernameText = data.username
|
||||||
|
|| localStorage.getItem("username")
|
||||||
|
|| "";
|
||||||
|
|
||||||
|
if (!dd) {
|
||||||
|
dd = document.createElement("div");
|
||||||
|
dd.id = "userDropdown";
|
||||||
|
dd.classList.add("user-dropdown");
|
||||||
|
|
||||||
|
// toggle button
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.id = "userDropdownToggle";
|
||||||
|
toggle.classList.add("btn","btn-user");
|
||||||
|
toggle.setAttribute("title", t("user_settings"));
|
||||||
|
toggle.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.append(toggle);
|
||||||
|
|
||||||
|
// menu
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.classList.add("user-menu");
|
||||||
|
menu.innerHTML = `
|
||||||
|
<div class="item" id="menuUserPanel">
|
||||||
|
<i class="material-icons folder-icon">person</i> ${t("user_panel")}
|
||||||
|
</div>
|
||||||
|
${data.isAdmin ? `
|
||||||
|
<div class="item" id="menuAdminPanel">
|
||||||
|
<i class="material-icons folder-icon">admin_panel_settings</i> ${t("admin_panel")}
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="item" id="menuApiDocs">
|
||||||
|
<i class="material-icons folder-icon">description</i> ${t("api_docs")}
|
||||||
|
</div>
|
||||||
|
<div class="item" id="menuLogout">
|
||||||
|
<i class="material-icons folder-icon">logout</i> ${t("logout")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dd.append(menu);
|
||||||
|
|
||||||
|
// insert
|
||||||
|
const dm = document.getElementById("darkModeToggle");
|
||||||
|
if (dm) insertAfter(dd, dm);
|
||||||
|
else if (firstButton) insertAfter(dd, firstButton);
|
||||||
|
else headerButtons.appendChild(dd);
|
||||||
|
|
||||||
|
// open/close
|
||||||
|
toggle.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.classList.toggle("show");
|
||||||
|
});
|
||||||
|
document.addEventListener("click", () => menu.classList.remove("show"));
|
||||||
|
|
||||||
|
// actions
|
||||||
|
document.getElementById("menuUserPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openUserPanel();
|
||||||
|
});
|
||||||
|
if (data.isAdmin) {
|
||||||
|
document.getElementById("menuAdminPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openAdminPanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById("menuApiDocs")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openApiModal();
|
||||||
|
});
|
||||||
|
document.getElementById("menuLogout")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
triggerLogout();
|
||||||
|
});
|
||||||
|
|
||||||
const adminBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminBtn) insertAfter(userPanelBtn, adminBtn);
|
|
||||||
else if (firstButton) insertAfter(userPanelBtn, firstButton);
|
|
||||||
else headerButtons.appendChild(userPanelBtn);
|
|
||||||
userPanelBtn.addEventListener("click", openUserPanel);
|
|
||||||
} else {
|
} else {
|
||||||
userPanelBtn.style.display = "block";
|
// update avatar & username only
|
||||||
|
const tog = dd.querySelector("#userDropdownToggle");
|
||||||
|
tog.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.style.display = "inline-block";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6) Finalize
|
||||||
initializeApp();
|
initializeApp();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
updateItemsPerPageSelect();
|
updateItemsPerPageSelect();
|
||||||
@@ -279,7 +431,8 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
return sendRequest("/api/auth/checkAuth.php")
|
return sendRequest("/api/auth/checkAuth.php")
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
@@ -301,6 +454,7 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
localStorage.setItem("readOnly", data.readOnly);
|
localStorage.setItem("readOnly", data.readOnly);
|
||||||
localStorage.setItem("disableUpload", data.disableUpload);
|
localStorage.setItem("disableUpload", data.disableUpload);
|
||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
applyProxyBypassUI();
|
||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
@@ -311,13 +465,14 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
document.getElementById('loginForm').style.display = '';
|
document.getElementById('loginForm').style.display = '';
|
||||||
if (showLoginToast) showToast("Please log in to continue.");
|
if (showLoginToast) showToast("Please log in to continue.");
|
||||||
toggleVisibility("loginForm", true);
|
toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
|
||||||
toggleVisibility("mainOperations", false);
|
toggleVisibility("mainOperations", false);
|
||||||
toggleVisibility("uploadFileForm", false);
|
toggleVisibility("uploadFileForm", false);
|
||||||
toggleVisibility("fileListContainer", false);
|
toggleVisibility("fileListContainer", false);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||||
|
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
export function setLastLoginData(data) {
|
export function setLastLoginData(data) {
|
||||||
@@ -60,14 +59,11 @@ export function openTOTPLoginModal() {
|
|||||||
const totpSection = document.getElementById("totpSection");
|
const totpSection = document.getElementById("totpSection");
|
||||||
const recoverySection = document.getElementById("recoverySection");
|
const recoverySection = document.getElementById("recoverySection");
|
||||||
const toggleLink = this;
|
const toggleLink = this;
|
||||||
|
|
||||||
if (recoverySection.style.display === "none") {
|
if (recoverySection.style.display === "none") {
|
||||||
// Switch to recovery
|
|
||||||
totpSection.style.display = "none";
|
totpSection.style.display = "none";
|
||||||
recoverySection.style.display = "block";
|
recoverySection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_totp_code_instead");
|
toggleLink.textContent = t("use_totp_code_instead");
|
||||||
} else {
|
} else {
|
||||||
// Switch back to TOTP
|
|
||||||
recoverySection.style.display = "none";
|
recoverySection.style.display = "none";
|
||||||
totpSection.style.display = "block";
|
totpSection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_recovery_code_instead");
|
toggleLink.textContent = t("use_recovery_code_instead");
|
||||||
@@ -93,7 +89,6 @@ export function openTOTPLoginModal() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(json => {
|
.then(json => {
|
||||||
if (json.status === "ok") {
|
if (json.status === "ok") {
|
||||||
// recovery succeeded → finalize login
|
|
||||||
window.location.href = "/index.html";
|
window.location.href = "/index.html";
|
||||||
} else {
|
} else {
|
||||||
showToast(json.message || t("recovery_code_verification_failed"));
|
showToast(json.message || t("recovery_code_verification_failed"));
|
||||||
@@ -107,17 +102,11 @@ export function openTOTPLoginModal() {
|
|||||||
// TOTP submission
|
// TOTP submission
|
||||||
const totpInput = document.getElementById("totpLoginInput");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
|
|
||||||
totpInput.addEventListener("input", async function () {
|
totpInput.addEventListener("input", async function () {
|
||||||
const code = this.value.trim();
|
const code = this.value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) return;
|
||||||
|
|
||||||
return;
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
}
|
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
if (!tokenRes.ok) {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +133,6 @@ export function openTOTPLoginModal() {
|
|||||||
} else {
|
} else {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.value = "";
|
this.value = "";
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -160,153 +148,288 @@ export function openTOTPLoginModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUserPanel() {
|
/**
|
||||||
const username = localStorage.getItem("username") || "User";
|
* Fetch current user info (username, profile_picture, totp_enabled)
|
||||||
let userPanelModal = document.getElementById("userPanelModal");
|
*/
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
async function fetchCurrentUser() {
|
||||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
try {
|
||||||
const modalContentStyles = `
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
credentials: 'include'
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchCurrentUser failed:', e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize any profile‐picture URL:
|
||||||
|
* - strip leading colons
|
||||||
|
* - ensure exactly one leading slash
|
||||||
|
*/
|
||||||
|
function normalizePicUrl(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// take only what's after the last colon
|
||||||
|
const parts = raw.split(':');
|
||||||
|
let pic = parts[parts.length - 1];
|
||||||
|
// strip any stray colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUserPanel() {
|
||||||
|
// 1) load data
|
||||||
|
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
|
||||||
|
const raw = profile_picture;
|
||||||
|
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
|
||||||
|
|
||||||
|
// 2) dark‐mode helpers
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
|
||||||
|
const contentStyle = `
|
||||||
|
background: ${isDark ? '#2c2c2c' : '#fff'};
|
||||||
|
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px;
|
max-width: 600px; width:90%;
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto; max-height: 500px;
|
||||||
overflow-x: hidden;
|
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||||
max-height: 383px !important;
|
|
||||||
flex-shrink: 0 !important;
|
|
||||||
scrollbar-gutter: stable both-edges;
|
|
||||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: none;
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
`;
|
`;
|
||||||
const savedLanguage = localStorage.getItem("language") || "en";
|
|
||||||
|
|
||||||
if (!userPanelModal) {
|
// 3) create or reuse modal
|
||||||
userPanelModal = document.createElement("div");
|
let modal = document.getElementById('userPanelModal');
|
||||||
userPanelModal.id = "userPanelModal";
|
if (!modal) {
|
||||||
userPanelModal.style.cssText = `
|
// overlay
|
||||||
position: fixed;
|
modal = document.createElement('div');
|
||||||
top: 0; right: 0; bottom: 0; left: 0;
|
modal.id = 'userPanelModal';
|
||||||
background-color: ${overlayBackground};
|
Object.assign(modal.style, {
|
||||||
display: flex;
|
position: 'fixed',
|
||||||
justify-content: center;
|
top: '0',
|
||||||
align-items: center;
|
left: '0',
|
||||||
z-index: 1000;
|
right: '0',
|
||||||
overflow: hidden;
|
bottom: '0',
|
||||||
|
background: overlayBg,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: '1000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// content container
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'modal-content';
|
||||||
|
content.style.cssText = contentStyle;
|
||||||
|
|
||||||
|
// close button
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.id = 'closeUserPanel';
|
||||||
|
closeBtn.className = 'editor-close-btn';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
|
||||||
|
// avatar + picker
|
||||||
|
const avatarWrapper = document.createElement('div');
|
||||||
|
avatarWrapper.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
const avatarInner = document.createElement('div');
|
||||||
|
avatarInner.style.cssText = 'position:relative; width:80px; height:80px; margin:0 auto;';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'profilePicPreview';
|
||||||
|
img.src = picUrl;
|
||||||
|
img.alt = 'Profile Picture';
|
||||||
|
img.style.cssText = 'width:100%; height:100%; border-radius:50%; object-fit:cover;';
|
||||||
|
avatarInner.appendChild(img);
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.htmlFor = 'profilePicInput';
|
||||||
|
label.style.cssText = `
|
||||||
|
position:absolute; bottom:0; right:0;
|
||||||
|
width:24px; height:24px;
|
||||||
|
background:rgba(0,0,0,0.6);
|
||||||
|
border-radius:50%; display:flex;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
cursor:pointer;
|
||||||
`;
|
`;
|
||||||
userPanelModal.innerHTML = `
|
const editIcon = document.createElement('i');
|
||||||
<div class="modal-content user-panel-content" style="${modalContentStyles}">
|
editIcon.className = 'material-icons';
|
||||||
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
editIcon.style.cssText = 'color:#fff; font-size:16px;';
|
||||||
<h3>${t("user_panel")} (${username})</h3>
|
editIcon.textContent = 'edit';
|
||||||
|
label.appendChild(editIcon);
|
||||||
|
avatarInner.appendChild(label);
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = 'profilePicInput';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
avatarInner.appendChild(fileInput);
|
||||||
|
avatarWrapper.appendChild(avatarInner);
|
||||||
|
content.appendChild(avatarWrapper);
|
||||||
|
|
||||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
|
// title
|
||||||
${t("change_password")}
|
const title = document.createElement('h3');
|
||||||
</button>
|
title.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
title.textContent = `${t('user_panel')} (${username})`;
|
||||||
|
content.appendChild(title);
|
||||||
|
|
||||||
<fieldset style="margin-bottom: 15px;">
|
// change password btn
|
||||||
<legend>${t("totp_settings")}</legend>
|
const pwdBtn = document.createElement('button');
|
||||||
<div class="form-group">
|
pwdBtn.id = 'openChangePasswordModalBtn';
|
||||||
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
|
pwdBtn.className = 'btn btn-primary';
|
||||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
pwdBtn.style.marginBottom = '15px';
|
||||||
</div>
|
pwdBtn.textContent = t('change_password');
|
||||||
</fieldset>
|
pwdBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('changePasswordModal').style.display = 'block';
|
||||||
<fieldset style="margin-bottom: 15px;">
|
|
||||||
<legend>${t("language")}</legend>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="languageSelector">${t("select_language")}:</label>
|
|
||||||
<select id="languageSelector">
|
|
||||||
<option value="en">${t("english")}</option>
|
|
||||||
<option value="es">${t("spanish")}</option>
|
|
||||||
<option value="fr">${t("french")}</option>
|
|
||||||
<option value="de">${t("german")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- New API Docs link -->
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
|
||||||
${t("api_docs") || "API Docs"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(userPanelModal);
|
|
||||||
|
|
||||||
const apiModal = document.createElement("div");
|
|
||||||
apiModal.id = "apiModal";
|
|
||||||
apiModal.style.cssText = `
|
|
||||||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
|
||||||
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// api.php
|
|
||||||
apiModal.innerHTML = `
|
|
||||||
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
|
||||||
<div class="editor-close-btn" id="closeApiModal">×</div>
|
|
||||||
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(apiModal);
|
|
||||||
|
|
||||||
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "flex";
|
|
||||||
});
|
|
||||||
document.getElementById("closeApiModal").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "none";
|
|
||||||
});
|
});
|
||||||
|
content.appendChild(pwdBtn);
|
||||||
|
|
||||||
// Handlers…
|
// TOTP fieldset
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
const totpFs = document.createElement('fieldset');
|
||||||
userPanelModal.style.display = "none";
|
totpFs.style.marginBottom = '15px';
|
||||||
});
|
const totpLegend = document.createElement('legend');
|
||||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
totpLegend.textContent = t('totp_settings');
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
totpFs.appendChild(totpLegend);
|
||||||
});
|
const totpLabel = document.createElement('label');
|
||||||
|
totpLabel.style.cursor = 'pointer';
|
||||||
|
const totpCb = document.createElement('input');
|
||||||
// TOTP checkbox
|
totpCb.type = 'checkbox';
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
totpCb.id = 'userTOTPEnabled';
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
totpCb.style.verticalAlign = 'middle';
|
||||||
totpCheckbox.addEventListener("change", function () {
|
totpCb.checked = totp_enabled;
|
||||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
totpCb.addEventListener('change', async function () {
|
||||||
fetch("/api/updateUserPanel.php", {
|
const resp = await fetch('/api/updateUserPanel.php', {
|
||||||
method: "POST",
|
method: 'POST', credentials: 'include',
|
||||||
credentials: "include",
|
headers: {
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
body: JSON.stringify({ totp_enabled: this.checked })
|
body: JSON.stringify({ totp_enabled: this.checked })
|
||||||
})
|
});
|
||||||
.then(r => r.json())
|
const js = await resp.json();
|
||||||
.then(result => {
|
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
|
||||||
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
|
else if (this.checked) openTOTPModal();
|
||||||
else if (this.checked) openTOTPModal();
|
|
||||||
})
|
|
||||||
.catch(() => showToast(t("error_updating_totp_setting")));
|
|
||||||
});
|
});
|
||||||
|
totpLabel.appendChild(totpCb);
|
||||||
|
totpLabel.append(` ${t('enable_totp')}`);
|
||||||
|
totpFs.appendChild(totpLabel);
|
||||||
|
content.appendChild(totpFs);
|
||||||
|
|
||||||
// Language selector
|
// language fieldset
|
||||||
const languageSelector = document.getElementById("languageSelector");
|
const langFs = document.createElement('fieldset');
|
||||||
languageSelector.value = savedLanguage;
|
langFs.style.marginBottom = '15px';
|
||||||
languageSelector.addEventListener("change", function () {
|
const langLegend = document.createElement('legend');
|
||||||
localStorage.setItem("language", this.value);
|
langLegend.textContent = t('language');
|
||||||
|
langFs.appendChild(langLegend);
|
||||||
|
const langSel = document.createElement('select');
|
||||||
|
langSel.id = 'languageSelector';
|
||||||
|
langSel.className = 'form-select';
|
||||||
|
const languages = [
|
||||||
|
{ code: 'en', labelKey: 'english', fallback: 'English' },
|
||||||
|
{ code: 'es', labelKey: 'spanish', fallback: 'Español' },
|
||||||
|
{ code: 'fr', labelKey: 'french', fallback: 'Français' },
|
||||||
|
{ code: 'de', labelKey: 'german', fallback: 'Deutsch' },
|
||||||
|
{ code: 'zh-CN', labelKey: 'chinese_simplified', fallback: '简体中文' },
|
||||||
|
];
|
||||||
|
|
||||||
|
languages.forEach(({ code, labelKey, fallback }) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = code;
|
||||||
|
// use i18n if available, otherwise fallback
|
||||||
|
opt.textContent = (typeof t === 'function' ? t(labelKey) : '') || fallback;
|
||||||
|
langSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
langSel.value = localStorage.getItem('language') || 'en';
|
||||||
|
langSel.addEventListener('change', function () {
|
||||||
|
localStorage.setItem('language', this.value);
|
||||||
setLocale(this.value);
|
setLocale(this.value);
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
|
dispCb.addEventListener('change', () => {
|
||||||
|
window.showFoldersInList = dispCb.checked;
|
||||||
|
localStorage.setItem('showFoldersInList', dispCb.checked);
|
||||||
|
// re‐load the entire file list (and strip) in one go:
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
// wire up image‐input change
|
||||||
|
fileInput.addEventListener('change', async function () {
|
||||||
|
const f = this.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
// preview immediately
|
||||||
|
// #nosec
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
const blobUrl = URL.createObjectURL(f);
|
||||||
|
// use setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
img.setAttribute('src', encodeURI(blobUrl));
|
||||||
|
// upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('profile_picture', f);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/uploadPicture.php', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const js = JSON.parse(text || '{}');
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(js.error || t('error_updating_picture'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newUrl = normalizePicUrl(js.url);
|
||||||
|
img.src = newUrl;
|
||||||
|
localStorage.setItem('profilePicUrl', newUrl);
|
||||||
|
updateAuthenticatedUI(window.__lastAuthData || {});
|
||||||
|
showToast(t('profile_picture_updated'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(t('error_updating_picture'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// finalize
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
} else {
|
} else {
|
||||||
// Update colors if already exists
|
// reuse on reopen
|
||||||
userPanelModal.style.backgroundColor = overlayBackground;
|
Object.assign(modal.style, { background: overlayBg });
|
||||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
const content = modal.querySelector('.modal-content');
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
content.style.cssText = contentStyle;
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
|
||||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||||||
|
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||||||
|
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
userPanelModal.style.display = "flex";
|
// show
|
||||||
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRecoveryCodeModal(recoveryCode) {
|
function showRecoveryCodeModal(recoveryCode) {
|
||||||
@@ -314,26 +437,21 @@ function showRecoveryCodeModal(recoveryCode) {
|
|||||||
recoveryModal.id = "recoveryModal";
|
recoveryModal.id = "recoveryModal";
|
||||||
recoveryModal.style.cssText = `
|
recoveryModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0; left: 0;
|
||||||
left: 0;
|
width: 100vw; height: 100vh;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: rgba(0,0,0,0.3);
|
background-color: rgba(0,0,0,0.3);
|
||||||
display: flex;
|
display: flex; justify-content: center; align-items: center;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3200;
|
z-index: 3200;
|
||||||
`;
|
`;
|
||||||
recoveryModal.innerHTML = `
|
recoveryModal.innerHTML = `
|
||||||
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
|
<div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
|
||||||
<h3>${t("your_recovery_code")}</h3>
|
<h3>${t("your_recovery_code")}</h3>
|
||||||
<p>${t("please_save_recovery_code")}</p>
|
<p>${t("please_save_recovery_code")}</p>
|
||||||
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
|
<code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
|
||||||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(recoveryModal);
|
document.body.appendChild(recoveryModal);
|
||||||
|
|
||||||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||||||
recoveryModal.remove();
|
recoveryModal.remove();
|
||||||
});
|
});
|
||||||
@@ -346,106 +464,54 @@ export function openTOTPModal() {
|
|||||||
const modalContentStyles = `
|
const modalContentStyles = `
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||||
padding: 20px;
|
padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
`;
|
||||||
if (!totpModal) {
|
if (!totpModal) {
|
||||||
totpModal = document.createElement("div");
|
totpModal = document.createElement("div");
|
||||||
totpModal.id = "totpModal";
|
totpModal.id = "totpModal";
|
||||||
totpModal.style.cssText = `
|
totpModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
top: 0;
|
background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
|
||||||
left: 0;
|
z-index:3100;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: ${overlayBackground};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3100;
|
|
||||||
`;
|
`;
|
||||||
totpModal.innerHTML = `
|
totpModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
||||||
<h3>${t("totp_setup")}</h3>
|
<h3>${t("totp_setup")}</h3>
|
||||||
<p>${t("scan_qr_code")}</p>
|
<p>${t("scan_qr_code")}</p>
|
||||||
<!-- Create an image placeholder without the CSRF token in the src -->
|
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
|
||||||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
<br/>
|
||||||
<br/>
|
<p>${t("enter_totp_confirmation")}</p>
|
||||||
<p>${t("enter_totp_confirmation")}</p>
|
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
<br/><br/>
|
||||||
<br/><br/>
|
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`;
|
|
||||||
document.body.appendChild(totpModal);
|
document.body.appendChild(totpModal);
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
|
||||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
|
||||||
closeTOTPModal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
||||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
|
||||||
showToast(t("please_enter_valid_code"));
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
return;
|
if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
|
||||||
}
|
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
showToast(t("error_verifying_totp_code"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { csrf_token } = await tokenRes.json();
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
|
|
||||||
const verifyRes = await fetch("/api/totp_verify.php", {
|
const verifyRes = await fetch("/api/totp_verify.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include",
|
||||||
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({ totp_code: code })
|
body: JSON.stringify({ totp_code: code })
|
||||||
});
|
});
|
||||||
|
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
|
||||||
if (!verifyRes.ok) {
|
|
||||||
showToast(t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await verifyRes.json();
|
const result = await verifyRes.json();
|
||||||
if (result.status !== "ok") {
|
if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
|
||||||
showToast(result.message || t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(t("totp_enabled_successfully"));
|
showToast(t("totp_enabled_successfully"));
|
||||||
|
|
||||||
const saveRes = await fetch("/api/totp_saveCode.php", {
|
const saveRes = await fetch("/api/totp_saveCode.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!saveRes.ok) {
|
if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
|
||||||
showToast(t("error_generating_recovery_code"));
|
|
||||||
closeTOTPModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await saveRes.json();
|
const data = await saveRes.json();
|
||||||
if (data.status === "ok" && data.recoveryCode) {
|
if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
|
||||||
showRecoveryCodeModal(data.recoveryCode);
|
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||||||
} else {
|
|
||||||
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTOTPModal(false);
|
closeTOTPModal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,29 +524,18 @@ export function openTOTPModal() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
totpModal.style.display = "flex";
|
totpModal.style.display = "flex";
|
||||||
totpModal.style.backgroundColor = overlayBackground;
|
totpModal.style.backgroundColor = overlayBackground;
|
||||||
const modalContent = totpModal.querySelector(".modal-content");
|
const modalContent = totpModal.querySelector(".modal-content");
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||||
|
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||||
// Clear any previous QR code src if needed and then load it:
|
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = "";
|
|
||||||
}
|
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
const totpInput = document.getElementById("totpConfirmInput");
|
||||||
// Focus the input and attach enter key listener
|
if (totpInput) {
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
totpInput.value = "";
|
||||||
if (totpConfirmInput) {
|
setTimeout(() => totpInput.focus(), 100);
|
||||||
totpConfirmInput.value = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
|
||||||
if (totpConfirmInput) totpConfirmInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
}
|
}
|
||||||
@@ -490,42 +545,31 @@ function loadTOTPQRCode() {
|
|||||||
fetch("/api/totp_setup.php", {
|
fetch("/api/totp_setup.php", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(res => {
|
||||||
if (!response.ok) {
|
if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
|
||||||
throw new Error("Failed to fetch QR code. Status: " + response.status);
|
return res.blob();
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
})
|
})
|
||||||
.then(blob => {
|
.then(blob => {
|
||||||
const imageURL = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
document.getElementById("totpQRCodeImage").src = url;
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = imageURL;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(err => {
|
||||||
console.error("Error loading TOTP QR code:", error);
|
console.error(err);
|
||||||
showToast(t("error_loading_qr_code"));
|
showToast(t("error_loading_qr_code"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated closeTOTPModal function with a disable parameter
|
|
||||||
export function closeTOTPModal(disable = true) {
|
export function closeTOTPModal(disable = true) {
|
||||||
const totpModal = document.getElementById("totpModal");
|
const totpModal = document.getElementById("totpModal");
|
||||||
if (totpModal) totpModal.style.display = "none";
|
if (totpModal) totpModal.style.display = "none";
|
||||||
|
|
||||||
if (disable) {
|
if (disable) {
|
||||||
// Uncheck the Enable TOTP checkbox
|
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
if (totpCheckbox) {
|
if (totpCheckbox) {
|
||||||
totpCheckbox.checked = false;
|
totpCheckbox.checked = false;
|
||||||
localStorage.setItem("userTOTPEnabled", "false");
|
localStorage.setItem("userTOTPEnabled", "false");
|
||||||
}
|
}
|
||||||
// Call endpoint to remove the TOTP secret from the user's record
|
|
||||||
fetch("/api/totp_disable.php", {
|
fetch("/api/totp_disable.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -536,10 +580,36 @@ export function closeTOTPModal(disable = true) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) {
|
if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||||||
showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => { showToast(t("error_disabling_totp_setting")); });
|
.catch(() => showToast(t("error_disabling_totp_setting")));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApiModal() {
|
||||||
|
let apiModal = document.getElementById("apiModal");
|
||||||
|
if (!apiModal) {
|
||||||
|
// create the container exactly as you do now inside openUserPanel
|
||||||
|
apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
// wire up its close button
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// finally, show it
|
||||||
|
apiModal.style.display = "flex";
|
||||||
}
|
}
|
||||||
@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
|||||||
export function updateFileActionButtons() {
|
export function updateFileActionButtons() {
|
||||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||||
const copyBtn = document.getElementById("copySelectedBtn");
|
const copyBtn = document.getElementById("copySelectedBtn");
|
||||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
|
||||||
const zipBtn = document.getElementById("downloadZipBtn");
|
const zipBtn = document.getElementById("downloadZipBtn");
|
||||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||||
|
const createBtn = document.getElementById("createBtn");
|
||||||
|
|
||||||
// keep the “select all” in sync ——
|
const anyFiles = fileCheckboxes.length > 0;
|
||||||
const master = document.getElementById("selectAll");
|
const anySelected = selectedCheckboxes.length > 0;
|
||||||
if (master) {
|
const anyZip = Array.from(selectedCheckboxes)
|
||||||
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
|
||||||
master.checked = true;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else if (selectedCheckboxes.length === 0) {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileCheckboxes.length === 0) {
|
// — Select All checkbox sync (unchanged) —
|
||||||
if (copyBtn) copyBtn.style.display = "none";
|
const master = document.getElementById("selectAll");
|
||||||
if (moveBtn) moveBtn.style.display = "none";
|
if (master) {
|
||||||
if (deleteBtn) deleteBtn.style.display = "none";
|
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
||||||
if (zipBtn) zipBtn.style.display = "none";
|
master.checked = true;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
master.indeterminate = false;
|
||||||
} else {
|
} else if (selectedCheckboxes.length === 0) {
|
||||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
master.indeterminate = false;
|
||||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
} else {
|
||||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
master.indeterminate = true;
|
||||||
|
|
||||||
const anySelected = selectedCheckboxes.length > 0;
|
|
||||||
if (copyBtn) copyBtn.disabled = !anySelected;
|
|
||||||
if (moveBtn) moveBtn.disabled = !anySelected;
|
|
||||||
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
|
||||||
if (zipBtn) zipBtn.disabled = !anySelected;
|
|
||||||
|
|
||||||
if (extractZipBtn) {
|
|
||||||
// Enable only if at least one selected file ends with .zip (case-insensitive).
|
|
||||||
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
|
|
||||||
chk.value.toLowerCase().endsWith(".zip")
|
|
||||||
);
|
|
||||||
extractZipBtn.disabled = !anyZipSelected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete / Copy / Move: only show when something is selected
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (moveBtn) {
|
||||||
|
moveBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download ZIP: only show when something is selected
|
||||||
|
if (zipBtn) {
|
||||||
|
zipBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ZIP: only show when a selected file is a .zip
|
||||||
|
if (extractZipBtn) {
|
||||||
|
extractZipBtn.style.display = anyZip ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create File: only show when nothing is selected
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.style.display = anySelected ? "none" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally disable the ones that are shown but shouldn’t be clickable
|
||||||
|
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
||||||
|
if (copyBtn) copyBtn.disabled = !anySelected;
|
||||||
|
if (moveBtn) moveBtn.disabled = !anySelected;
|
||||||
|
if (zipBtn) zipBtn.disabled = !anySelected;
|
||||||
|
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(message, duration = 3000) {
|
export function showToast(message, duration = 3000) {
|
||||||
@@ -178,9 +190,14 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
|
previewButton = `<button
|
||||||
${previewIcon}
|
type="button"
|
||||||
</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 `
|
return `
|
||||||
@@ -194,19 +211,44 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeSize}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-success download-btn"
|
||||||
|
data-download-name="${file.name}"
|
||||||
|
data-download-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('download')}">
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
|
<button
|
||||||
<i class="material-icons">edit</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-sm btn-secondary edit-btn"
|
||||||
` : ""}
|
data-edit-name="${file.name}"
|
||||||
|
data-edit-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('edit')}">
|
||||||
|
<i class="material-icons">edit</i>
|
||||||
|
</button>` : ""}
|
||||||
|
|
||||||
${previewButton}
|
${previewButton}
|
||||||
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning rename-btn"
|
||||||
|
data-rename-name="${file.name}"
|
||||||
|
data-rename-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('rename')}">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -76,6 +76,72 @@ export function handleDownloadZipSelected(e) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function handleCreateFileSelected(e) {
|
||||||
|
e.preventDefault(); e.stopImmediatePropagation();
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
const inp = document.getElementById('newFileCreateName');
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the “New File” modal
|
||||||
|
*/
|
||||||
|
export function openCreateFileModal() {
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!modal || !input) {
|
||||||
|
console.error('Create-file modal or input not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function handleCreateFile(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!input) return console.error('Create-file input missing');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('newfile_placeholder')); // or a more explicit error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
// ⚠️ must send `name`, not `filename`
|
||||||
|
body: JSON.stringify({ folder, name })
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!js.success) throw new Error(js.error);
|
||||||
|
showToast(t('file_created'));
|
||||||
|
loadFileList(folder);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
} finally {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
|
});
|
||||||
|
|
||||||
export function openDownloadModal(fileName, folder) {
|
export function openDownloadModal(fileName, folder) {
|
||||||
// Store file details globally for the download confirmation function.
|
// Store file details globally for the download confirmation function.
|
||||||
window.singleFileToDownload = fileName;
|
window.singleFileToDownload = fileName;
|
||||||
@@ -197,6 +263,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const progressModal = document.getElementById("downloadProgressModal");
|
const progressModal = document.getElementById("downloadProgressModal");
|
||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
|
if (cancelCreate) {
|
||||||
|
cancelCreate.addEventListener('click', () => {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCreate = document.getElementById('confirmCreateFile');
|
||||||
|
if (confirmCreate) {
|
||||||
|
confirmCreate.addEventListener('click', async () => {
|
||||||
|
const name = document.getElementById('newFileCreateName').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('please_enter_filename'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: window.currentFolder || 'root',
|
||||||
|
filename: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!res.ok || !js.success) {
|
||||||
|
throw new Error(js.error || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
showToast(t('file_created_successfully'));
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
if (cancelZipBtn) {
|
if (cancelZipBtn) {
|
||||||
@@ -553,8 +662,14 @@ export function initFileActions() {
|
|||||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||||
}
|
}
|
||||||
|
const createBtn = document.getElementById('createFileBtn');
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.replaceWith(createBtn.cloneNode(true));
|
||||||
|
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Hook up the single‐file download modal buttons
|
// Hook up the single‐file download modal buttons
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
||||||
@@ -573,4 +688,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const btn = document.getElementById('createBtn');
|
||||||
|
const menu = document.getElementById('createMenu');
|
||||||
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
|
const folderOpt= document.getElementById('createFolderOption');
|
||||||
|
|
||||||
|
// Toggle dropdown on click
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create File
|
||||||
|
fileOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
openCreateFileModal(); // your existing function
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Folder
|
||||||
|
folderOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
document.getElementById('createFolderModal').style.display = 'block';
|
||||||
|
document.getElementById('newFolderName').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close if you click anywhere else
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -3,22 +3,168 @@ import { escapeHTML, showToast } from './domUtils.js';
|
|||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
|
// thresholds for editor behavior
|
||||||
|
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||||
|
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||||
|
|
||||||
|
// Lazy-load CodeMirror modes on demand
|
||||||
|
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||||
|
|
||||||
|
// Which mode file to load for a given name/mime
|
||||||
|
const MODE_URL = {
|
||||||
|
// core/common
|
||||||
|
"xml": "mode/xml/xml.min.js",
|
||||||
|
"css": "mode/css/css.min.js",
|
||||||
|
"javascript": "mode/javascript/javascript.min.js",
|
||||||
|
|
||||||
|
// meta / combos
|
||||||
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||||
|
"application/x-httpd-php": "mode/php/php.min.js",
|
||||||
|
|
||||||
|
// docs / data
|
||||||
|
"markdown": "mode/markdown/markdown.min.js",
|
||||||
|
"yaml": "mode/yaml/yaml.min.js",
|
||||||
|
"properties": "mode/properties/properties.min.js",
|
||||||
|
"sql": "mode/sql/sql.min.js",
|
||||||
|
|
||||||
|
// shells
|
||||||
|
"shell": "mode/shell/shell.min.js",
|
||||||
|
|
||||||
|
// languages
|
||||||
|
"python": "mode/python/python.min.js",
|
||||||
|
"text/x-csrc": "mode/clike/clike.min.js",
|
||||||
|
"text/x-c++src": "mode/clike/clike.min.js",
|
||||||
|
"text/x-java": "mode/clike/clike.min.js",
|
||||||
|
"text/x-csharp": "mode/clike/clike.min.js",
|
||||||
|
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map any mime/alias to the key we use in MODE_URL
|
||||||
|
function normalizeModeName(modeOption) {
|
||||||
|
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||||
|
if (!name) return null;
|
||||||
|
if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML
|
||||||
|
if (name === "php") return "application/x-httpd-php"; // prefer the full mime
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_SRI = {
|
||||||
|
"mode/xml/xml.min.js": "sha512-LarNmzVokUmcA7aUDtqZ6oTS+YXmUKzpGdm8DxC46A6AHu+PQiYCUlwEGWidjVYMo/QXZMFMIadZtrkfApYp/g==",
|
||||||
|
"mode/css/css.min.js": "sha512-oikhYLgIKf0zWtVTOXh101BWoSacgv4UTJHQOHU+iUQ1Dol3Xjz/o9Jh0U33MPoT/d4aQruvjNvcYxvkTQd0nA==",
|
||||||
|
"mode/javascript/javascript.min.js": "sha512-I6CdJdruzGtvDyvdO4YsiAq+pkWf2efgd1ZUSK2FnM/u2VuRASPC7GowWQrWyjxCZn6CT89s3ddGI+be0Ak9Fg==",
|
||||||
|
"mode/htmlmixed/htmlmixed.min.js": "sha512-HN6cn6mIWeFJFwRN9yetDAMSh+AK9myHF1X9GlSlKmThaat65342Yw8wL7ITuaJnPioG0SYG09gy0qd5+s777w==",
|
||||||
|
"mode/php/php.min.js": "sha512-jZGz5n9AVTuQGhKTL0QzOm6bxxIQjaSbins+vD3OIdI7mtnmYE6h/L+UBGIp/SssLggbkxRzp9XkQNA4AyjFBw==",
|
||||||
|
"mode/markdown/markdown.min.js": "sha512-DmMao0nRIbyDjbaHc8fNd3kxGsZj9PCU6Iu/CeidLQT9Py8nYVA5n0PqXYmvqNdU+lCiTHOM/4E7bM/G8BttJg==",
|
||||||
|
"mode/python/python.min.js": "sha512-2M0GdbU5OxkGYMhakED69bw0c1pW3Nb0PeF3+9d+SnwN1ryPx3wiDdNqK3gSM7KAU/pEV+2tFJFbMKjKAahOkQ==",
|
||||||
|
"mode/sql/sql.min.js": "sha512-u8r8NUnG9B9L2dDmsfvs9ohQ0SO/Z7MB8bkdLxV7fE0Q8bOeP7/qft1D4KyE8HhVrpH3ihSrRoDiMbYR1VQBWQ==",
|
||||||
|
"mode/shell/shell.min.js": "sha512-HoC6JXgjHHevWAYqww37Gfu2c1G7SxAOv42wOakjR8csbTUfTB7OhVzSJ95LL62nII0RCyImp+7nR9zGmJ1wRQ==",
|
||||||
|
"mode/yaml/yaml.min.js": "sha512-+aXDZ93WyextRiAZpsRuJyiAZ38ztttUyO/H3FZx4gOAOv4/k9C6Um1CvHVtaowHZ2h7kH0d+orWvdBLPVwb4g==",
|
||||||
|
"mode/properties/properties.min.js": "sha512-P4OaO+QWj1wPRsdkEHlrgkx+a7qp6nUC8rI6dS/0/HPjHtlEmYfiambxowYa/UfqTxyNUnwTyPt5U6l1GO76yw==",
|
||||||
|
"mode/clike/clike.min.js": "sha512-l8ZIWnQ3XHPRG3MQ8+hT1OffRSTrFwrph1j1oc1Fzc9UKVGef5XN9fdO0vm3nW0PRgQ9LJgck6ciG59m69rvfg=="
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||||
|
|
||||||
|
function loadScriptOnce(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const key = `cm:${url}`;
|
||||||
|
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||||
|
if (s) {
|
||||||
|
if (s.dataset.loaded === "1") return resolve();
|
||||||
|
s.addEventListener("load", () => resolve());
|
||||||
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s = document.createElement("script");
|
||||||
|
s.src = url;
|
||||||
|
s.async = true;
|
||||||
|
s.dataset.key = key;
|
||||||
|
|
||||||
|
// 🔒 Add SRI if we have it
|
||||||
|
const relPath = url.replace(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/codemirror\/5\.65\.5\//, "");
|
||||||
|
const sri = MODE_SRI[relPath];
|
||||||
|
if (sri) {
|
||||||
|
s.integrity = sri;
|
||||||
|
s.crossOrigin = "anonymous";
|
||||||
|
// (Optional) further tighten referrer behavior:
|
||||||
|
// s.referrerPolicy = "no-referrer";
|
||||||
|
}
|
||||||
|
|
||||||
|
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||||
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function ensureModeLoaded(modeOption) {
|
||||||
|
if (!window.CodeMirror) return;
|
||||||
|
|
||||||
|
const name = normalizeModeName(modeOption);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const isRegistered = () =>
|
||||||
|
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
|
||||||
|
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]);
|
||||||
|
|
||||||
|
if (isRegistered()) return;
|
||||||
|
|
||||||
|
const url = MODE_URL[name];
|
||||||
|
if (!url) return; // unknown -> stay in text/plain
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
if (name === "htmlmixed") {
|
||||||
|
await Promise.all([
|
||||||
|
ensureModeLoaded("xml"),
|
||||||
|
ensureModeLoaded("css"),
|
||||||
|
ensureModeLoaded("javascript")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (name === "application/x-httpd-php") {
|
||||||
|
await ensureModeLoaded("htmlmixed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadScriptOnce(CM_CDN + url);
|
||||||
|
}
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
|
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case "css":
|
|
||||||
return "css";
|
|
||||||
case "json":
|
|
||||||
return { name: "javascript", json: true };
|
|
||||||
case "js":
|
|
||||||
return "javascript";
|
|
||||||
case "html":
|
case "html":
|
||||||
case "htm":
|
case "htm": return "text/html";
|
||||||
return "text/html";
|
case "xml": return "xml";
|
||||||
case "xml":
|
case "md":
|
||||||
return "xml";
|
case "markdown": return "markdown";
|
||||||
default:
|
case "yml":
|
||||||
return "text/plain";
|
case "yaml": return "yaml";
|
||||||
|
case "css": return "css";
|
||||||
|
case "js": return "javascript";
|
||||||
|
case "json": return { name: "javascript", json: true };
|
||||||
|
case "php": return "application/x-httpd-php";
|
||||||
|
case "py": return "python";
|
||||||
|
case "sql": return "sql";
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
case "zsh":
|
||||||
|
case "bat": return "shell";
|
||||||
|
case "ini":
|
||||||
|
case "conf":
|
||||||
|
case "config":
|
||||||
|
case "properties": return "properties";
|
||||||
|
case "c":
|
||||||
|
case "h": return "text/x-csrc";
|
||||||
|
case "cpp":
|
||||||
|
case "cxx":
|
||||||
|
case "hpp":
|
||||||
|
case "hh":
|
||||||
|
case "hxx": return "text/x-c++src";
|
||||||
|
case "java": return "text/x-java";
|
||||||
|
case "cs": return "text/x-csharp";
|
||||||
|
case "kt":
|
||||||
|
case "kts": return "text/x-kotlin";
|
||||||
|
default: return "text/plain";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export { getModeForFile };
|
export { getModeForFile };
|
||||||
@@ -35,18 +181,16 @@ export { adjustEditorSize };
|
|||||||
|
|
||||||
function observeModalResize(modal) {
|
function observeModalResize(modal) {
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
|
||||||
adjustEditorSize();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(modal);
|
resizeObserver.observe(modal);
|
||||||
}
|
}
|
||||||
export { observeModalResize };
|
export { observeModalResize };
|
||||||
|
|
||||||
export function editFile(fileName, folder) {
|
export function editFile(fileName, folder) {
|
||||||
|
// destroy any previous editor
|
||||||
let existingEditor = document.getElementById("editorContainer");
|
let existingEditor = document.getElementById("editorContainer");
|
||||||
if (existingEditor) {
|
if (existingEditor) existingEditor.remove();
|
||||||
existingEditor.remove();
|
|
||||||
}
|
|
||||||
const folderUsed = folder || window.currentFolder || "root";
|
const folderUsed = folder || window.currentFolder || "root";
|
||||||
const folderPath = folderUsed === "root"
|
const folderPath = folderUsed === "root"
|
||||||
? "uploads/"
|
? "uploads/"
|
||||||
@@ -55,99 +199,162 @@ export function editFile(fileName, folder) {
|
|||||||
|
|
||||||
fetch(fileUrl, { method: "HEAD" })
|
fetch(fileUrl, { method: "HEAD" })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const contentLength = response.headers.get("Content-Length");
|
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
|
||||||
|
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||||
throw new Error("File too large.");
|
throw new Error("File too large.");
|
||||||
}
|
}
|
||||||
return fetch(fileUrl);
|
return response;
|
||||||
})
|
})
|
||||||
|
.then(() => fetch(fileUrl))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
||||||
throw new Error("HTTP error! Status: " + response.status);
|
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||||
}
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
return response.text();
|
return Promise.all([response.text(), sizeBytes]);
|
||||||
})
|
})
|
||||||
.then(content => {
|
.then(([content, sizeBytes]) => {
|
||||||
|
const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||||
|
|
||||||
|
// --- Build modal immediately and wire close controls BEFORE any async loads ---
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.id = "editorContainer";
|
modal.id = "editorContainer";
|
||||||
modal.classList.add("modal", "editor-modal");
|
modal.classList.add("modal", "editor-modal");
|
||||||
|
modal.setAttribute("tabindex", "-1"); // for Escape handling
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
|
<h3 class="editor-title">
|
||||||
<div class="editor-controls">
|
${t("editing")}: ${escapeHTML(fileName)}
|
||||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
${forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""}
|
||||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
</h3>
|
||||||
|
<div class="editor-controls">
|
||||||
|
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||||
|
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||||
|
</div>
|
||||||
|
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close")}">×</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeEditorX" class="editor-close-btn">×</button>
|
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||||
</div>
|
<div class="editor-footer">
|
||||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
<button id="saveBtn" class="btn btn-primary" disabled>${t("save")}</button>
|
||||||
<div class="editor-footer">
|
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||||
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
|
</div>
|
||||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = "block";
|
modal.style.display = "block";
|
||||||
|
modal.focus();
|
||||||
|
|
||||||
const mode = getModeForFile(fileName);
|
let canceled = false;
|
||||||
|
const doClose = () => {
|
||||||
|
canceled = true;
|
||||||
|
window.currentEditor = null;
|
||||||
|
modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire close actions right away
|
||||||
|
modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); });
|
||||||
|
document.getElementById("closeEditorX").addEventListener("click", doClose);
|
||||||
|
document.getElementById("closeBtn").addEventListener("click", doClose);
|
||||||
|
|
||||||
|
// Keep buttons responsive even before editor exists
|
||||||
|
const decBtn = document.getElementById("decreaseFont");
|
||||||
|
const incBtn = document.getElementById("increaseFont");
|
||||||
|
decBtn.addEventListener("click", () => {});
|
||||||
|
incBtn.addEventListener("click", () => {});
|
||||||
|
|
||||||
|
// Theme + mode selection
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
const theme = isDarkMode ? "material-darker" : "default";
|
const theme = isDarkMode ? "material-darker" : "default";
|
||||||
|
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||||
|
|
||||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
// Helper to check whether a mode is currently registered
|
||||||
lineNumbers: true,
|
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name);
|
||||||
mode: mode,
|
const isModeRegistered = () =>
|
||||||
theme: theme,
|
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) ||
|
||||||
viewportMargin: Infinity
|
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]);
|
||||||
});
|
|
||||||
|
|
||||||
window.currentEditor = editor;
|
// Start mode loading (don’t block closing)
|
||||||
|
const modePromise = ensureModeLoaded(desiredMode);
|
||||||
|
|
||||||
setTimeout(() => {
|
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
||||||
adjustEditorSize();
|
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
||||||
}, 50);
|
|
||||||
|
|
||||||
observeModalResize(modal);
|
Promise.race([modePromise, timeout]).then(() => {
|
||||||
|
if (canceled) return;
|
||||||
|
if (!window.CodeMirror) {
|
||||||
|
// Core not present: keep plain <textarea>; enable Save and bail gracefully
|
||||||
|
document.getElementById("saveBtn").disabled = false;
|
||||||
|
observeModalResize(modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let currentFontSize = 14;
|
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode;
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
const cmOptions = {
|
||||||
editor.refresh();
|
lineNumbers: !forcePlainText,
|
||||||
|
mode: initialMode,
|
||||||
|
theme,
|
||||||
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
|
lineWrapping: false
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
const editor = window.CodeMirror.fromTextArea(
|
||||||
modal.remove();
|
document.getElementById("fileEditor"),
|
||||||
});
|
cmOptions
|
||||||
|
);
|
||||||
|
window.currentEditor = editor;
|
||||||
|
|
||||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
setTimeout(adjustEditorSize, 50);
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
observeModalResize(modal);
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
|
// Font controls (now that editor exists)
|
||||||
|
let currentFontSize = 14;
|
||||||
|
const wrapper = editor.getWrapperElement();
|
||||||
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
|
|
||||||
|
decBtn.addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
incBtn.addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveBtn = document.getElementById("saveBtn");
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.addEventListener("click", function () {
|
||||||
|
saveFile(fileName, folderUsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Theme switch
|
||||||
|
function updateEditorTheme() {
|
||||||
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
|
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
|
}
|
||||||
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
|
|
||||||
|
// If we started in plain text due to timeout, flip to the real mode once it arrives
|
||||||
|
modePromise.then(() => {
|
||||||
|
if (!canceled && !forcePlainText && isModeRegistered()) {
|
||||||
|
editor.setOption("mode", desiredMode);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// If the mode truly fails to load, we just stay in plain text
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
editor.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
|
||||||
saveFile(fileName, folderUsed);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateEditorTheme() {
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
|
||||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error loading file:", error));
|
.catch(error => {
|
||||||
|
if (error && error.name === "AbortError") return;
|
||||||
|
console.error("Error loading file:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function saveFile(fileName, folder) {
|
export function saveFile(fileName, folder) {
|
||||||
const editor = window.currentEditor;
|
const editor = window.currentEditor;
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
import { updateRowHighlight, showToast } from './domUtils.js';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js';
|
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
|
||||||
import { previewFile } from './filePreview.js';
|
import { previewFile } from './filePreview.js';
|
||||||
import { editFile } from './fileEditor.js';
|
import { editFile } from './fileEditor.js';
|
||||||
import { canEditFile, fileData } from './fileListView.js';
|
import { canEditFile, fileData } from './fileListView.js';
|
||||||
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
|
|||||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||||
|
|
||||||
let menuItems = [
|
let menuItems = [
|
||||||
|
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
||||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ const translations = {
|
|||||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||||
"admin_panel": "Admin Panel",
|
"admin_panel": "Admin Panel",
|
||||||
"user_panel": "User Panel",
|
"user_panel": "User Panel",
|
||||||
|
"user_settings": "User Settings",
|
||||||
|
"save_profile_picture": "Save Profile Picture",
|
||||||
|
"please_select_picture": "Please select a picture",
|
||||||
|
"profile_picture_updated": "Profile picture updated",
|
||||||
|
"error_updating_picture": "Error updating profile picture",
|
||||||
"trash_restore_delete": "Trash Restore/Delete",
|
"trash_restore_delete": "Trash Restore/Delete",
|
||||||
"totp_settings": "TOTP Settings",
|
"totp_settings": "TOTP Settings",
|
||||||
"enable_totp": "Enable TOTP",
|
"enable_totp": "Enable TOTP",
|
||||||
@@ -211,6 +216,7 @@ const translations = {
|
|||||||
"spanish": "Spanish",
|
"spanish": "Spanish",
|
||||||
"french": "French",
|
"french": "French",
|
||||||
"german": "German",
|
"german": "German",
|
||||||
|
"chinese_simplified": "Chinese (Simplified)",
|
||||||
"use_totp_code_instead": "Use TOTP Code instead",
|
"use_totp_code_instead": "Use TOTP Code instead",
|
||||||
"submit_recovery_code": "Submit Recovery Code",
|
"submit_recovery_code": "Submit Recovery Code",
|
||||||
"please_enter_recovery_code": "Please enter your recovery code.",
|
"please_enter_recovery_code": "Please enter your recovery code.",
|
||||||
@@ -260,7 +266,43 @@ const translations = {
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"items_per_page": "items per page",
|
"items_per_page": "items per page",
|
||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"api_docs": "API Docs"
|
"row_height": "Row Height",
|
||||||
|
"api_docs": "API Docs",
|
||||||
|
"show_folders_above_files": "Show folders above files",
|
||||||
|
"display": "Display",
|
||||||
|
"create_file": "Create File",
|
||||||
|
"create_new_file": "Create New File",
|
||||||
|
"enter_file_name": "Enter file name",
|
||||||
|
"newfile_placeholder": "New file name",
|
||||||
|
"file_created_successfully": "File created successfully!",
|
||||||
|
"error_creating_file": "Error creating file",
|
||||||
|
"file_created": "File created successfully!",
|
||||||
|
"no_access_to_resource": "You do not have access to this resource.",
|
||||||
|
"can_share": "Can Share",
|
||||||
|
"bypass_ownership": "Bypass Ownership",
|
||||||
|
"error_loading_user_grants": "Error loading user grants",
|
||||||
|
"click_to_edit": "Click to edit",
|
||||||
|
"folder_access": "Folder Access",
|
||||||
|
"move_folder": "Move Folder",
|
||||||
|
"move_folder_message": "Select a destination folder to move this folder to:",
|
||||||
|
"move_folder_title": "Move this folder",
|
||||||
|
"move_folder_success": "Folder moved successfully.",
|
||||||
|
"move_folder_error": "Error moving folder.",
|
||||||
|
"move_folder_invalid": "Invalid source or destination folder.",
|
||||||
|
"move_folder_denied": "You do not have permission to move this folder.",
|
||||||
|
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
|
||||||
|
"move_folder_same_owner": "Source and destination must have the same owner.",
|
||||||
|
"move_folder_confirm": "Are you sure you want to move this folder?",
|
||||||
|
"move_folder_select_dest": "Select a destination folder",
|
||||||
|
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
|
||||||
|
"acl_move_folder_label": "Move Folder (source)",
|
||||||
|
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
|
||||||
|
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
|
||||||
|
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
@@ -443,6 +485,7 @@ const translations = {
|
|||||||
"spanish": "Español",
|
"spanish": "Español",
|
||||||
"french": "Francés",
|
"french": "Francés",
|
||||||
"german": "Alemán",
|
"german": "Alemán",
|
||||||
|
"chinese_simplified": "Chino (simplificado)",
|
||||||
"use_totp_code_instead": "Usar código TOTP en su lugar",
|
"use_totp_code_instead": "Usar código TOTP en su lugar",
|
||||||
"submit_recovery_code": "Enviar código de recuperación",
|
"submit_recovery_code": "Enviar código de recuperación",
|
||||||
"please_enter_recovery_code": "Por favor, ingrese su código de recuperación.",
|
"please_enter_recovery_code": "Por favor, ingrese su código de recuperación.",
|
||||||
@@ -671,6 +714,7 @@ const translations = {
|
|||||||
"spanish": "Espagnol",
|
"spanish": "Espagnol",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"german": "Allemand",
|
"german": "Allemand",
|
||||||
|
"chinese_simplified": "Chinois (simplifié)",
|
||||||
"use_totp_code_instead": "Utiliser le code TOTP à la place",
|
"use_totp_code_instead": "Utiliser le code TOTP à la place",
|
||||||
"submit_recovery_code": "Soumettre le code de récupération",
|
"submit_recovery_code": "Soumettre le code de récupération",
|
||||||
"please_enter_recovery_code": "Veuillez entrer votre code de récupération.",
|
"please_enter_recovery_code": "Veuillez entrer votre code de récupération.",
|
||||||
@@ -908,6 +952,7 @@ const translations = {
|
|||||||
"spanish": "Spanisch",
|
"spanish": "Spanisch",
|
||||||
"french": "Französisch",
|
"french": "Französisch",
|
||||||
"german": "Deutsch",
|
"german": "Deutsch",
|
||||||
|
"chinese_simplified": "Chinesisch (vereinfacht)",
|
||||||
"use_totp_code_instead": "Stattdessen TOTP-Code verwenden",
|
"use_totp_code_instead": "Stattdessen TOTP-Code verwenden",
|
||||||
"submit_recovery_code": "Wiederherstellungscode absenden",
|
"submit_recovery_code": "Wiederherstellungscode absenden",
|
||||||
"please_enter_recovery_code": "Bitte geben Sie Ihren Wiederherstellungscode ein.",
|
"please_enter_recovery_code": "Bitte geben Sie Ihren Wiederherstellungscode ein.",
|
||||||
@@ -957,7 +1002,275 @@ const translations = {
|
|||||||
"show": "Zeige",
|
"show": "Zeige",
|
||||||
"items_per_page": "elemente pro seite",
|
"items_per_page": "elemente pro seite",
|
||||||
"columns": "Spalten"
|
"columns": "Spalten"
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
"please_log_in_to_continue": "请登录以继续。",
|
||||||
|
"no_files_selected": "未选择文件。",
|
||||||
|
"confirm_delete_files": "确定要删除所选的 {count} 个文件吗?",
|
||||||
|
"element_not_found": "未找到 ID 为 \"{id}\" 的元素。",
|
||||||
|
"search_placeholder": "搜索文件、标签和上传者…",
|
||||||
|
"search_placeholder_advanced": "高级搜索:文件、标签、上传者和内容…",
|
||||||
|
"basic_search_tooltip": "基础搜索:按文件名、标签和上传者搜索。",
|
||||||
|
"advanced_search_tooltip": "高级搜索:包括文件内容、文件名、标签和上传者。",
|
||||||
|
"file_name": "文件名",
|
||||||
|
"date_modified": "修改日期",
|
||||||
|
"upload_date": "上传日期",
|
||||||
|
"file_size": "文件大小",
|
||||||
|
"uploader": "上传者",
|
||||||
|
"enter_totp_code": "输入 TOTP 验证码",
|
||||||
|
"use_recovery_code_instead": "改用恢复代码",
|
||||||
|
"enter_recovery_code": "输入恢复代码",
|
||||||
|
"editing": "正在编辑",
|
||||||
|
"decrease_font": "A-",
|
||||||
|
"increase_font": "A+",
|
||||||
|
"save": "保存",
|
||||||
|
"close": "关闭",
|
||||||
|
"no_files_found": "未找到文件。",
|
||||||
|
"switch_to_table_view": "切换到表格视图",
|
||||||
|
"switch_to_gallery_view": "切换到图库视图",
|
||||||
|
"share_file": "分享文件",
|
||||||
|
"set_expiration": "设置到期时间:",
|
||||||
|
"password_optional": "密码(可选):",
|
||||||
|
"generate_share_link": "生成分享链接",
|
||||||
|
"shareable_link": "可分享链接:",
|
||||||
|
"copy_link": "复制链接",
|
||||||
|
"tag_file": "标记文件",
|
||||||
|
"tag_name": "标签名称:",
|
||||||
|
"tag_color": "标签颜色:",
|
||||||
|
"save_tag": "保存标签",
|
||||||
|
"light_mode": "浅色模式",
|
||||||
|
"dark_mode": "深色模式",
|
||||||
|
"upload_instruction": "将文件/文件夹拖到此处,或点击“选择文件”",
|
||||||
|
"no_files_selected_default": "未选择文件",
|
||||||
|
"choose_files": "选择文件",
|
||||||
|
"delete_selected": "删除所选",
|
||||||
|
"copy_selected": "复制所选",
|
||||||
|
"move_selected": "移动所选",
|
||||||
|
"tag_selected": "标记所选",
|
||||||
|
"download_zip": "下载 ZIP",
|
||||||
|
"extract_zip": "解压 ZIP",
|
||||||
|
"preview": "预览",
|
||||||
|
"edit": "编辑",
|
||||||
|
"rename": "重命名",
|
||||||
|
"trash_empty": "回收站为空。",
|
||||||
|
"no_trash_selected": "未选择要还原的回收站项目。",
|
||||||
|
|
||||||
|
"title": "FileRise",
|
||||||
|
"header_title": "FileRise",
|
||||||
|
"header_title_text": "标题文本",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"change_password": "更改密码",
|
||||||
|
"restore_text": "还原或",
|
||||||
|
"delete_text": "删除回收站项目",
|
||||||
|
"restore_selected": "还原所选",
|
||||||
|
"restore_all": "全部还原",
|
||||||
|
"delete_selected_trash": "删除所选",
|
||||||
|
"delete_all": "全部删除",
|
||||||
|
"upload_header": "上传文件/文件夹",
|
||||||
|
|
||||||
|
"folder_navigation": "文件夹导航与管理",
|
||||||
|
"create_folder": "创建文件夹",
|
||||||
|
"create_folder_title": "创建文件夹",
|
||||||
|
"enter_folder_name": "输入文件夹名称",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"rename_folder": "重命名文件夹",
|
||||||
|
"rename_folder_title": "重命名文件夹",
|
||||||
|
"rename_folder_placeholder": "输入新的文件夹名称",
|
||||||
|
"delete_folder": "删除文件夹",
|
||||||
|
"delete_folder_title": "删除文件夹",
|
||||||
|
"delete_folder_message": "确定要删除此文件夹吗?",
|
||||||
|
"folder_help": "文件夹帮助",
|
||||||
|
"folder_help_item_1": "点击文件夹以查看其中的文件。",
|
||||||
|
"folder_help_item_2": "使用 [-] 折叠,使用 [+] 展开文件夹。",
|
||||||
|
"folder_help_item_3": "选择一个文件夹并点击“创建文件夹”以添加子文件夹。",
|
||||||
|
"folder_help_item_4": "要重命名或删除文件夹,请选择后点击相应按钮。",
|
||||||
|
|
||||||
|
"actions": "操作",
|
||||||
|
"file_list_title": "文件列表(根目录)",
|
||||||
|
"files_in": "文件位于",
|
||||||
|
"delete_files": "删除文件",
|
||||||
|
"delete_selected_files_title": "删除所选文件",
|
||||||
|
"delete_files_message": "确定要删除所选文件吗?",
|
||||||
|
"copy_files": "复制文件",
|
||||||
|
"copy_files_title": "复制所选文件",
|
||||||
|
"copy_files_message": "选择目标文件夹以复制所选文件:",
|
||||||
|
"move_files": "移动文件",
|
||||||
|
"move_files_title": "移动所选文件",
|
||||||
|
"move_files_message": "选择目标文件夹以移动所选文件:",
|
||||||
|
"move": "移动",
|
||||||
|
"extract_zip_button": "解压 ZIP",
|
||||||
|
"download_zip_title": "将所选文件打包为 ZIP 下载",
|
||||||
|
"download_zip_prompt": "输入 ZIP 文件名:",
|
||||||
|
"zip_placeholder": "files.zip",
|
||||||
|
"share": "分享",
|
||||||
|
"total_files": "文件总数",
|
||||||
|
"total_size": "总大小",
|
||||||
|
"prev": "上一页",
|
||||||
|
"next": "下一页",
|
||||||
|
"page": "第",
|
||||||
|
"of": "页,共",
|
||||||
|
|
||||||
|
"login": "登录",
|
||||||
|
"remember_me": "记住我",
|
||||||
|
"login_oidc": "使用 OIDC 登录",
|
||||||
|
"basic_http_login": "使用基本 HTTP 登录",
|
||||||
|
|
||||||
|
"change_password_title": "更改密码",
|
||||||
|
"old_password": "旧密码",
|
||||||
|
"new_password": "新密码",
|
||||||
|
"confirm_new_password": "确认新密码",
|
||||||
|
|
||||||
|
"create_new_user_title": "创建新用户",
|
||||||
|
"username": "用户名:",
|
||||||
|
"password": "密码:",
|
||||||
|
"enter_password": "密码",
|
||||||
|
"preparing_download": "正在准备下载…",
|
||||||
|
"download_file": "下载文件",
|
||||||
|
"confirm_or_change_filename": "确认或修改下载文件名:",
|
||||||
|
"filename": "文件名",
|
||||||
|
"download": "下载",
|
||||||
|
"grant_admin": "授予管理员权限",
|
||||||
|
"save_user": "保存用户",
|
||||||
|
|
||||||
|
"remove_user_title": "删除用户",
|
||||||
|
"select_user_remove": "选择要删除的用户:",
|
||||||
|
"delete_user": "删除用户",
|
||||||
|
|
||||||
|
"rename_file_title": "重命名文件",
|
||||||
|
"rename_file_placeholder": "输入新的文件名",
|
||||||
|
|
||||||
|
"share_folder": "分享文件夹",
|
||||||
|
"allow_uploads": "允许上传",
|
||||||
|
"share_link_generated": "已生成分享链接",
|
||||||
|
"error_generating_share_link": "生成分享链接时出错",
|
||||||
|
"custom": "自定义",
|
||||||
|
"duration": "持续时间",
|
||||||
|
"seconds": "秒",
|
||||||
|
"minutes": "分钟",
|
||||||
|
"hours": "小时",
|
||||||
|
"days": "天",
|
||||||
|
"custom_duration_warning": "⚠️ 使用较长的到期时间可能存在安全风险,请谨慎使用。",
|
||||||
|
|
||||||
|
"folder_share": "分享文件夹",
|
||||||
|
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"unsaved_changes_confirm": "您有未保存的更改,确定要关闭而不保存吗?",
|
||||||
|
"delete": "删除",
|
||||||
|
"upload": "上传",
|
||||||
|
"copy": "复制",
|
||||||
|
"extract": "解压",
|
||||||
|
"user": "用户:",
|
||||||
|
"unknown_error": "未知错误",
|
||||||
|
"link_copied": "链接已复制到剪贴板",
|
||||||
|
"weeks": "周",
|
||||||
|
"months": "月",
|
||||||
|
|
||||||
|
"dark_mode_toggle": "深色模式",
|
||||||
|
"light_mode_toggle": "浅色模式",
|
||||||
|
"switch_to_light_mode": "切换到浅色模式",
|
||||||
|
"switch_to_dark_mode": "切换到深色模式",
|
||||||
|
|
||||||
|
"header_settings": "标题设置",
|
||||||
|
"shared_max_upload_size_bytes_title": "共享最大上传大小",
|
||||||
|
"shared_max_upload_size_bytes": "共享最大上传大小(字节)",
|
||||||
|
"max_bytes_shared_uploads_note": "请输入共享文件夹上传的最大允许字节数",
|
||||||
|
"manage_shared_links": "管理分享链接",
|
||||||
|
"folder_shares": "文件夹分享",
|
||||||
|
"file_shares": "文件分享",
|
||||||
|
"loading": "正在加载…",
|
||||||
|
"error_loading_share_links": "加载分享链接时出错",
|
||||||
|
"share_deleted_successfully": "分享已成功删除",
|
||||||
|
"error_deleting_share": "删除分享时出错",
|
||||||
|
"password_protected": "受密码保护",
|
||||||
|
"no_shared_links_available": "暂无可用的分享链接",
|
||||||
|
|
||||||
|
"admin_panel": "管理员面板",
|
||||||
|
"user_panel": "用户面板",
|
||||||
|
"user_settings": "用户设置",
|
||||||
|
"save_profile_picture": "保存头像",
|
||||||
|
"please_select_picture": "请选择图片",
|
||||||
|
"profile_picture_updated": "头像已更新",
|
||||||
|
"error_updating_picture": "更新头像时出错",
|
||||||
|
"trash_restore_delete": "回收站恢复/删除",
|
||||||
|
"totp_settings": "TOTP 设置",
|
||||||
|
"enable_totp": "启用 TOTP",
|
||||||
|
"language": "语言",
|
||||||
|
"select_language": "选择语言",
|
||||||
|
"english": "英语",
|
||||||
|
"spanish": "西班牙语",
|
||||||
|
"french": "法语",
|
||||||
|
"german": "德语",
|
||||||
|
"chinese_simplified": "简体中文",
|
||||||
|
"use_totp_code_instead": "改用 TOTP 验证码",
|
||||||
|
"submit_recovery_code": "提交恢复代码",
|
||||||
|
"please_enter_recovery_code": "请输入您的恢复代码。",
|
||||||
|
"recovery_code_verification_failed": "恢复代码验证失败",
|
||||||
|
"error_verifying_recovery_code": "验证恢复代码时出错",
|
||||||
|
"totp_verification_failed": "TOTP 验证失败",
|
||||||
|
"error_verifying_totp_code": "验证 TOTP 代码时出错",
|
||||||
|
"totp_setup": "TOTP 设置",
|
||||||
|
"scan_qr_code": "请使用验证器应用扫描此二维码。",
|
||||||
|
"enter_totp_confirmation": "输入应用生成的 6 位验证码以确认设置:",
|
||||||
|
"confirm": "确认",
|
||||||
|
"please_enter_valid_code": "请输入有效的 6 位验证码。",
|
||||||
|
"totp_enabled_successfully": "TOTP 启用成功。",
|
||||||
|
"error_generating_recovery_code": "生成恢复代码时出错",
|
||||||
|
"error_loading_qr_code": "加载二维码时出错。",
|
||||||
|
"error_disabling_totp_setting": "禁用 TOTP 设置时出错",
|
||||||
|
"user_management": "用户管理",
|
||||||
|
"add_user": "添加用户",
|
||||||
|
"remove_user": "删除用户",
|
||||||
|
"user_permissions": "用户权限",
|
||||||
|
"oidc_configuration": "OIDC 配置",
|
||||||
|
"oidc_provider_url": "OIDC 提供者 URL",
|
||||||
|
"oidc_client_id": "OIDC 客户端 ID",
|
||||||
|
"oidc_client_secret": "OIDC 客户端密钥",
|
||||||
|
"oidc_redirect_uri": "OIDC 重定向 URI",
|
||||||
|
"global_totp_settings": "全局 TOTP 设置",
|
||||||
|
"global_otpauth_url": "全局 OTPAuth URL",
|
||||||
|
"login_options": "登录选项",
|
||||||
|
"disable_login_form": "禁用登录表单",
|
||||||
|
"disable_basic_http_auth": "禁用基本 HTTP 认证",
|
||||||
|
"disable_oidc_login": "禁用 OIDC 登录",
|
||||||
|
"save_settings": "保存设置",
|
||||||
|
"at_least_one_login_method": "至少保留一种登录方式。",
|
||||||
|
"settings_updated_successfully": "设置已成功更新。",
|
||||||
|
"error_updating_settings": "更新设置时出错",
|
||||||
|
"user_permissions_updated_successfully": "用户权限已成功更新。",
|
||||||
|
"error_updating_permissions": "更新权限时出错",
|
||||||
|
"no_users_found": "未找到用户。",
|
||||||
|
"user_folder_only": "仅限用户文件夹",
|
||||||
|
"read_only": "只读",
|
||||||
|
"disable_upload": "禁用上传",
|
||||||
|
"error_loading_users": "加载用户时出错",
|
||||||
|
"save_permissions": "保存权限",
|
||||||
|
"your_recovery_code": "您的恢复代码",
|
||||||
|
"please_save_recovery_code": "请妥善保存此代码。此代码仅显示一次且只能使用一次。",
|
||||||
|
"ok": "确定",
|
||||||
|
"show": "显示",
|
||||||
|
"items_per_page": "每页项目数",
|
||||||
|
"columns": "列",
|
||||||
|
"row_height": "行高",
|
||||||
|
"api_docs": "API 文档",
|
||||||
|
"show_folders_above_files": "在文件上方显示文件夹",
|
||||||
|
"display": "显示",
|
||||||
|
"create_file": "创建文件",
|
||||||
|
"create_new_file": "创建新文件",
|
||||||
|
"enter_file_name": "输入文件名",
|
||||||
|
"newfile_placeholder": "新文件名",
|
||||||
|
"file_created_successfully": "文件创建成功!",
|
||||||
|
"error_creating_file": "创建文件时出错",
|
||||||
|
"file_created": "文件创建成功!",
|
||||||
|
"no_access_to_resource": "您无权访问此资源。",
|
||||||
|
"can_share": "可分享",
|
||||||
|
"bypass_ownership": "绕过所有权限制",
|
||||||
|
"error_loading_user_grants": "加载用户授权时出错",
|
||||||
|
"click_to_edit": "点击编辑",
|
||||||
|
"folder_access": "文件夹访问"
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js';
|
|||||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
||||||
import { initUpload } from './upload.js';
|
import { initUpload } from './upload.js';
|
||||||
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
||||||
const _originalFetch = window.fetch;
|
|
||||||
window.fetch = fetchWithCsrf;
|
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { loadFolderTree } from './folderManager.js';
|
||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||||
@@ -14,59 +12,182 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
|||||||
import { editFile, saveFile } from './fileEditor.js';
|
import { editFile, saveFile } from './fileEditor.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
CSRF HOTFIX UTILITIES
|
||||||
|
========================= */
|
||||||
|
const _nativeFetch = window.fetch; // keep the real fetch
|
||||||
|
|
||||||
|
function setCsrfToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
window.csrfToken = token;
|
||||||
|
localStorage.setItem('csrf', token);
|
||||||
|
|
||||||
|
// meta tag for easy access in other places
|
||||||
|
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 || localStorage.getItem('csrf') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed CSRF from storage ASAP (before any requests)
|
||||||
|
setCsrfToken(getCsrfToken());
|
||||||
|
|
||||||
|
// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers.
|
||||||
|
async function fetchWithCsrfAndRefresh(input, init = {}) {
|
||||||
|
const res = await fetchWithCsrf(input, init);
|
||||||
|
try {
|
||||||
|
const rotated = res.headers?.get('X-CSRF-Token');
|
||||||
|
if (rotated) setCsrfToken(rotated);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace global fetch with the wrapped version so *all* callers benefit.
|
||||||
|
window.fetch = fetchWithCsrfAndRefresh;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
SAFE API HELPERS
|
||||||
|
========================= */
|
||||||
|
export async function apiGETJSON(url, opts = {}) {
|
||||||
|
const res = await fetch(url, { credentials: "include", ...opts });
|
||||||
|
if (res.status === 401) throw new Error("auth");
|
||||||
|
if (res.status === 403) throw new Error("forbidden");
|
||||||
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
||||||
|
try { return await res.json(); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPOSTJSON(url, body, opts = {}) {
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": getCsrfToken(),
|
||||||
|
...(opts.headers || {})
|
||||||
|
};
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
...opts
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new Error("auth");
|
||||||
|
if (res.status === 403) throw new Error("forbidden");
|
||||||
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
||||||
|
try { return await res.json(); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: expose on window for legacy callers
|
||||||
|
window.apiGETJSON = apiGETJSON;
|
||||||
|
window.apiPOSTJSON = apiPOSTJSON;
|
||||||
|
|
||||||
|
// Global handler to keep UX friendly if something forgets to catch
|
||||||
|
window.addEventListener("unhandledrejection", (ev) => {
|
||||||
|
const msg = (ev?.reason && ev.reason.message) || "";
|
||||||
|
if (msg === "auth") {
|
||||||
|
showToast(t("please_sign_in_again") || "Please sign in again.", "error");
|
||||||
|
ev.preventDefault();
|
||||||
|
} else if (msg === "forbidden") {
|
||||||
|
showToast(t("no_access_to_resource") || "You don’t have access to that.", "error");
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
APP INIT
|
||||||
|
========================= */
|
||||||
|
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
window.currentFolder = "root";
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
|
|
||||||
|
//window.currentFolder = "root";
|
||||||
|
const last = localStorage.getItem('lastOpenedFolder');
|
||||||
|
window.currentFolder = last ? last : "root";
|
||||||
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
|
loadAdminConfigFunc();
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
//loadFileList(window.currentFolder);
|
||||||
|
|
||||||
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
|
if (fileListArea && uploadArea) {
|
||||||
|
fileListArea.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.add('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('dragleave', () => {
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||||
|
dataTransfer: e.dataTransfer,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
loadSidebarOrder();
|
loadSidebarOrder();
|
||||||
loadHeaderOrder();
|
loadHeaderOrder();
|
||||||
initFileActions();
|
initFileActions();
|
||||||
initUpload();
|
initUpload();
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
setupTrashRestoreDelete();
|
// Only run trash/restore for admins
|
||||||
loadAdminConfigFunc();
|
const isAdmin =
|
||||||
|
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||||
|
if (isAdmin) {
|
||||||
|
setupTrashRestoreDelete();
|
||||||
|
}
|
||||||
|
|
||||||
const helpBtn = document.getElementById("folderHelpBtn");
|
const helpBtn = document.getElementById("folderHelpBtn");
|
||||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||||
if (helpBtn && helpTooltip) {
|
if (helpBtn && helpTooltip) {
|
||||||
helpBtn.addEventListener("click", () => {
|
helpBtn.addEventListener("click", () => {
|
||||||
helpTooltip.style.display =
|
helpTooltip.style.display =
|
||||||
helpTooltip.style.display === "block" ? "none" : "block";
|
helpTooltip.style.display === "block" ? "none" : "block";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap/refresh CSRF from the server.
|
||||||
|
* Uses the *native* fetch to avoid any wrapper loops and to work even if we don't
|
||||||
|
* yet have a token. Also accepts a rotated token from the response header.
|
||||||
|
*/
|
||||||
export function loadCsrfToken() {
|
export function loadCsrfToken() {
|
||||||
return fetchWithCsrf('/api/auth/token.php', {
|
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
|
||||||
method: 'GET'
|
.then(async res => {
|
||||||
})
|
// header-based rotation
|
||||||
.then(res => {
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
if (!res.ok) {
|
if (hdr) setCsrfToken(hdr);
|
||||||
throw new Error(`Token fetch failed with status ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(({ csrf_token, share_url }) => {
|
|
||||||
// Update global and <meta>
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement('meta');
|
|
||||||
meta.name = 'csrf-token';
|
|
||||||
document.head.appendChild(meta);
|
|
||||||
}
|
|
||||||
meta.content = csrf_token;
|
|
||||||
|
|
||||||
|
// body (if provided)
|
||||||
|
let body = {};
|
||||||
|
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
|
||||||
|
|
||||||
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
|
||||||
|
// share-url meta should reflect the actual origin
|
||||||
|
const actualShare = window.location.origin;
|
||||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||||
if (!shareMeta) {
|
if (!shareMeta) {
|
||||||
shareMeta = document.createElement('meta');
|
shareMeta = document.createElement('meta');
|
||||||
shareMeta.name = 'share-url';
|
shareMeta.name = 'share-url';
|
||||||
document.head.appendChild(shareMeta);
|
document.head.appendChild(shareMeta);
|
||||||
}
|
}
|
||||||
shareMeta.content = share_url;
|
shareMeta.content = actualShare;
|
||||||
|
|
||||||
return { csrf_token, share_url };
|
return { csrf_token: token, share_url: actualShare };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,21 +198,16 @@ if (params.get('logout') === '1') {
|
|||||||
localStorage.removeItem("userTOTPEnabled");
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Wire up logoutBtn right away
|
export function triggerLogout() {
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
_nativeFetch("/api/auth/logout.php", {
|
||||||
if (logoutBtn) {
|
method: "POST",
|
||||||
logoutBtn.addEventListener("click", () => {
|
credentials: "include",
|
||||||
fetch("/api/auth/logout.php", {
|
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||||
method: "POST",
|
})
|
||||||
credentials: "include",
|
.then(() => window.location.reload(true))
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
.catch(() => { });
|
||||||
})
|
|
||||||
.then(() => window.location.reload(true))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Expose functions for inline handlers.
|
// Expose functions for inline handlers.
|
||||||
window.sendRequest = sendRequest;
|
window.sendRequest = sendRequest;
|
||||||
window.toggleVisibility = toggleVisibility;
|
window.toggleVisibility = toggleVisibility;
|
||||||
@@ -106,105 +222,80 @@ window.openDownloadModal = openDownloadModal;
|
|||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Load admin config early
|
||||||
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
loadAdminConfigFunc(); // Then fetch the latest config and update.
|
// i18n
|
||||||
// Retrieve the saved language from localStorage; default to "en"
|
|
||||||
const savedLanguage = localStorage.getItem("language") || "en";
|
const savedLanguage = localStorage.getItem("language") || "en";
|
||||||
// Set the locale based on the saved language
|
|
||||||
setLocale(savedLanguage);
|
setLocale(savedLanguage);
|
||||||
// Apply the translations to update the UI
|
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
// First, load the CSRF token (with retry).
|
|
||||||
loadCsrfToken().then(() => {
|
|
||||||
// Once CSRF token is loaded, initialize authentication.
|
|
||||||
initAuth();
|
|
||||||
|
|
||||||
// Continue with initializations that rely on a valid CSRF token:
|
// 1) Get/refresh CSRF first
|
||||||
checkAuthentication().then(authenticated => {
|
loadCsrfToken()
|
||||||
if (authenticated) {
|
.then(() => {
|
||||||
document.getElementById('loadingOverlay').remove();
|
// 2) Auth boot
|
||||||
initializeApp();
|
initAuth();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Other DOM initialization that can happen after CSRF is ready.
|
// 3) If authenticated, start app
|
||||||
const newPasswordInput = document.getElementById("newPassword");
|
checkAuthentication().then(authenticated => {
|
||||||
if (newPasswordInput) {
|
if (authenticated) {
|
||||||
newPasswordInput.addEventListener("input", function () {
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
console.log("newPassword input event:", this.value);
|
if (overlay) overlay.remove();
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error("newPassword input not found!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dark Mode Persistence ---
|
// --- Dark Mode Persistence ---
|
||||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||||
const darkModeIcon = document.getElementById("darkModeIcon");
|
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||||
|
|
||||||
if (darkModeToggle && darkModeIcon) {
|
if (darkModeToggle && darkModeIcon) {
|
||||||
// 1) Load stored preference (or null)
|
let stored = localStorage.getItem("darkMode");
|
||||||
let stored = localStorage.getItem("darkMode");
|
const hasStored = stored !== null;
|
||||||
const hasStored = stored !== null;
|
|
||||||
|
|
||||||
// 2) Determine initial mode
|
const isDark = hasStored
|
||||||
const isDark = hasStored
|
? (stored === "true")
|
||||||
? (stored === "true")
|
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
|
|
||||||
document.body.classList.toggle("dark-mode", isDark);
|
document.body.classList.toggle("dark-mode", isDark);
|
||||||
darkModeToggle.classList.toggle("active", isDark);
|
darkModeToggle.classList.toggle("active", isDark);
|
||||||
|
|
||||||
// 3) Helper to update icon & aria-label
|
function updateIcon() {
|
||||||
function updateIcon() {
|
const dark = document.body.classList.contains("dark-mode");
|
||||||
const dark = document.body.classList.contains("dark-mode");
|
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
||||||
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
|
||||||
darkModeToggle.setAttribute(
|
darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
|
||||||
"aria-label",
|
}
|
||||||
dark ? t("light_mode") : t("dark_mode")
|
|
||||||
);
|
|
||||||
darkModeToggle.setAttribute(
|
|
||||||
"title",
|
|
||||||
dark
|
|
||||||
? t("switch_to_light_mode")
|
|
||||||
: t("switch_to_dark_mode")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIcon();
|
|
||||||
|
|
||||||
// 4) Click handler: always override and store preference
|
|
||||||
darkModeToggle.addEventListener("click", () => {
|
|
||||||
const nowDark = document.body.classList.toggle("dark-mode");
|
|
||||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
|
||||||
updateIcon();
|
updateIcon();
|
||||||
});
|
|
||||||
|
|
||||||
// 5) OS‐level change: only if no stored pref at load
|
darkModeToggle.addEventListener("click", () => {
|
||||||
if (!hasStored && window.matchMedia) {
|
const nowDark = document.body.classList.toggle("dark-mode");
|
||||||
window
|
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||||
.matchMedia("(prefers-color-scheme: dark)")
|
updateIcon();
|
||||||
.addEventListener("change", e => {
|
});
|
||||||
|
|
||||||
|
if (!hasStored && window.matchMedia) {
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
|
||||||
document.body.classList.toggle("dark-mode", e.matches);
|
document.body.classList.toggle("dark-mode", e.matches);
|
||||||
updateIcon();
|
updateIcon();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// --- End Dark Mode Persistence ---
|
||||||
// --- End Dark Mode Persistence ---
|
|
||||||
|
|
||||||
const message = sessionStorage.getItem("welcomeMessage");
|
const message = sessionStorage.getItem("welcomeMessage");
|
||||||
if (message) {
|
if (message) {
|
||||||
showToast(message);
|
showToast(message);
|
||||||
sessionStorage.removeItem("welcomeMessage");
|
sessionStorage.removeItem("welcomeMessage");
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
})
|
||||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
.catch(error => {
|
||||||
});
|
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Auto-scroll During Drag ---
|
// --- Auto-scroll During Drag ---
|
||||||
// Adjust these values as needed:
|
const SCROLL_THRESHOLD = 50;
|
||||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
const SCROLL_SPEED = 20;
|
||||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
|
||||||
|
|
||||||
document.addEventListener("dragover", function (e) {
|
document.addEventListener("dragover", function (e) {
|
||||||
if (e.clientY < SCROLL_THRESHOLD) {
|
if (e.clientY < SCROLL_THRESHOLD) {
|
||||||
window.scrollBy(0, -SCROLL_SPEED);
|
window.scrollBy(0, -SCROLL_SPEED);
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
// Always report what we actually restored
|
||||||
showToast(data.success);
|
if (files.length === 1) {
|
||||||
toggleVisibility("restoreFilesModal", false);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
if (files.length === 1) {
|
||||||
showToast(data.success);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
toggleVisibility("restoreFilesModal", false);
|
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -161,91 +161,91 @@ function createFileEntry(file) {
|
|||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.classList.add("remove-file-btn");
|
removeBtn.classList.add("remove-file-btn");
|
||||||
removeBtn.textContent = "×";
|
removeBtn.textContent = "×";
|
||||||
// In your remove button event listener, replace the fetch call with:
|
// In your remove button event listener, replace the fetch call with:
|
||||||
removeBtn.addEventListener("click", function (e) {
|
removeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const uploadIndex = file.uploadIndex;
|
const uploadIndex = file.uploadIndex;
|
||||||
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
||||||
|
|
||||||
// Cancel the file upload if possible.
|
// Cancel the file upload if possible.
|
||||||
if (typeof file.cancel === "function") {
|
if (typeof file.cancel === "function") {
|
||||||
file.cancel();
|
file.cancel();
|
||||||
console.log("Canceled file upload:", file.fileName);
|
console.log("Canceled file upload:", file.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove file from the resumable queue.
|
// Remove file from the resumable queue.
|
||||||
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
||||||
resumableInstance.removeFile(file);
|
resumableInstance.removeFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call our helper repeatedly to remove the chunk folder.
|
// Call our helper repeatedly to remove the chunk folder.
|
||||||
if (file.uniqueIdentifier) {
|
if (file.uniqueIdentifier) {
|
||||||
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
li.remove();
|
li.remove();
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
});
|
});
|
||||||
li.removeBtn = removeBtn;
|
li.removeBtn = removeBtn;
|
||||||
li.appendChild(removeBtn);
|
li.appendChild(removeBtn);
|
||||||
|
|
||||||
// Add pause/resume/restart button if the file supports pause/resume.
|
// Add pause/resume/restart button if the file supports pause/resume.
|
||||||
// Conditionally add the pause/resume button only if file.pause is available
|
// Conditionally add the pause/resume button only if file.pause is available
|
||||||
// Pause/Resume button (for resumable file–picker uploads)
|
// Pause/Resume button (for resumable file–picker uploads)
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
const pauseResumeBtn = document.createElement("button");
|
const pauseResumeBtn = document.createElement("button");
|
||||||
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
||||||
pauseResumeBtn.classList.add("pause-resume-btn");
|
pauseResumeBtn.classList.add("pause-resume-btn");
|
||||||
// Start with pause icon and disable button until upload starts
|
// Start with pause icon and disable button until upload starts
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.disabled = true;
|
pauseResumeBtn.disabled = true;
|
||||||
pauseResumeBtn.addEventListener("click", function (e) {
|
pauseResumeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (file.isError) {
|
if (file.isError) {
|
||||||
// If the file previously failed, try restarting upload.
|
// If the file previously failed, try restarting upload.
|
||||||
if (typeof file.retry === "function") {
|
if (typeof file.retry === "function") {
|
||||||
file.retry();
|
file.retry();
|
||||||
file.isError = false;
|
file.isError = false;
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
}
|
}
|
||||||
} else if (!file.paused) {
|
} else if (!file.paused) {
|
||||||
// Pause the upload (if possible)
|
// Pause the upload (if possible)
|
||||||
if (typeof file.pause === "function") {
|
|
||||||
file.pause();
|
|
||||||
file.paused = true;
|
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
} else if (file.paused) {
|
|
||||||
// Resume sequence: first call to resume (or upload() fallback)
|
|
||||||
if (typeof file.resume === "function") {
|
|
||||||
file.resume();
|
|
||||||
} else {
|
|
||||||
resumableInstance.upload();
|
|
||||||
}
|
|
||||||
// After a short delay, pause again then resume
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.pause();
|
file.pause();
|
||||||
|
file.paused = true;
|
||||||
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
} else if (file.paused) {
|
||||||
|
// Resume sequence: first call to resume (or upload() fallback)
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
// After a short delay, pause again then resume
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof file.resume === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.resume();
|
file.pause();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
|
} else {
|
||||||
|
resumableInstance.upload();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, 100);
|
file.paused = false;
|
||||||
file.paused = false;
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
} else {
|
||||||
} else {
|
console.error("Pause/resume function not available for file", file);
|
||||||
console.error("Pause/resume function not available for file", file);
|
}
|
||||||
}
|
});
|
||||||
});
|
li.appendChild(pauseResumeBtn);
|
||||||
li.appendChild(pauseResumeBtn);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Preview element
|
// Preview element
|
||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
@@ -406,20 +406,27 @@ let resumableInstance;
|
|||||||
function initResumableUpload() {
|
function initResumableUpload() {
|
||||||
resumableInstance = new Resumable({
|
resumableInstance = new Resumable({
|
||||||
target: "/api/upload/upload.php",
|
target: "/api/upload/upload.php",
|
||||||
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
|
|
||||||
simultaneousUploads: 3,
|
simultaneousUploads: 3,
|
||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: false,
|
||||||
throttleProgressCallbacks: 1,
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
query: {
|
query: () => ({
|
||||||
folder: window.currentFolder || "root",
|
folder: window.currentFolder || "root",
|
||||||
upload_token: window.csrfToken // still as a fallback
|
upload_token: window.csrfToken
|
||||||
}
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// keep query fresh when folder changes (call this from your folder nav code)
|
||||||
|
function updateResumableQuery() {
|
||||||
|
if (!resumableInstance) return;
|
||||||
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
|
// if you're not using a function for query, do:
|
||||||
|
resumableInstance.opts.query.folder = window.currentFolder || 'root';
|
||||||
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
// Assign Resumable to file input for file picker uploads.
|
// Assign Resumable to file input for file picker uploads.
|
||||||
@@ -432,6 +439,7 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resumableInstance.on("fileAdded", function (file) {
|
resumableInstance.on("fileAdded", function (file) {
|
||||||
|
|
||||||
// Initialize custom paused flag
|
// Initialize custom paused flag
|
||||||
file.paused = false;
|
file.paused = false;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -461,16 +469,17 @@ function initResumableUpload() {
|
|||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
updateResumableQuery();
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function(file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
const progress = file.progress(); // value between 0 and 1
|
const progress = file.progress(); // value between 0 and 1
|
||||||
const percent = Math.floor(progress * 100);
|
const percent = Math.floor(progress * 100);
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||||
if (li && li.progressBar) {
|
if (li && li.progressBar) {
|
||||||
if (percent < 99) {
|
if (percent < 99) {
|
||||||
li.progressBar.style.width = percent + "%";
|
li.progressBar.style.width = percent + "%";
|
||||||
|
|
||||||
// Calculate elapsed time and speed.
|
// Calculate elapsed time and speed.
|
||||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
let speed = "";
|
let speed = "";
|
||||||
@@ -491,7 +500,7 @@ function initResumableUpload() {
|
|||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable the pause/resume button once progress starts.
|
// Enable the pause/resume button once progress starts.
|
||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) {
|
if (pauseResumeBtn) {
|
||||||
@@ -499,8 +508,8 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function(file, message) {
|
resumableInstance.on("fileSuccess", function (file, message) {
|
||||||
// Try to parse JSON response
|
// Try to parse JSON response
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -508,18 +517,18 @@ function initResumableUpload() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Soft‐fail CSRF? then update token & retry this file
|
// 1) Soft‐fail CSRF? then update token & retry this file
|
||||||
if (data && data.csrf_expired) {
|
if (data && data.csrf_expired) {
|
||||||
// Update global and Resumable headers
|
// Update global and Resumable headers
|
||||||
window.csrfToken = data.csrf_token;
|
window.csrfToken = data.csrf_token;
|
||||||
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
||||||
resumableInstance.opts.query.upload_token = data.csrf_token;
|
resumableInstance.opts.query.upload_token = data.csrf_token;
|
||||||
// Retry this chunk/file
|
// Retry this chunk/file
|
||||||
file.retry();
|
file.retry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Otherwise treat as real success:
|
// 2) Otherwise treat as real success:
|
||||||
const li = document.querySelector(
|
const li = document.querySelector(
|
||||||
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||||
@@ -531,13 +540,13 @@ function initResumableUpload() {
|
|||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
||||||
const removeBtn = li.querySelector(".remove-file-btn");
|
const removeBtn = li.querySelector(".remove-file-btn");
|
||||||
if (removeBtn) removeBtn.style.display = "none";
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
setTimeout(() => li.remove(), 5000);
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
resumableInstance.on("fileError", function (file, message) {
|
resumableInstance.on("fileError", function (file, message) {
|
||||||
@@ -637,7 +646,7 @@ function submitFiles(allFiles) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
jsonResponse = null;
|
jsonResponse = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
||||||
if (jsonResponse && jsonResponse.csrf_expired) {
|
if (jsonResponse && jsonResponse.csrf_expired) {
|
||||||
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
||||||
@@ -650,10 +659,10 @@ function submitFiles(allFiles) {
|
|||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
// real success
|
// real success
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -662,6 +671,7 @@ function submitFiles(allFiles) {
|
|||||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = true;
|
uploadResults[file.uploadIndex] = true;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// real failure
|
// real failure
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -669,12 +679,29 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
allSucceeded = false;
|
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>`;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
}
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("error", function () {
|
xhr.addEventListener("error", function () {
|
||||||
@@ -687,6 +714,9 @@ function submitFiles(allFiles) {
|
|||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
// Immediate summary toast based on actual XHR outcomes
|
||||||
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
|
const failedCount = allFiles.length - succeededCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -713,17 +743,30 @@ function submitFiles(allFiles) {
|
|||||||
loadFileList(folderToUse)
|
loadFileList(folderToUse)
|
||||||
.then(serverFiles => {
|
.then(serverFiles => {
|
||||||
initFileActions();
|
initFileActions();
|
||||||
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
|
// Be tolerant to API shapes: string or object with name/fileName/filename
|
||||||
|
serverFiles = (serverFiles || [])
|
||||||
|
.map(item => {
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
|
||||||
|
return String(n);
|
||||||
|
})
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
let overallSuccess = true;
|
let overallSuccess = true;
|
||||||
|
let succeeded = 0;
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const clientFileName = file.name.trim().toLowerCase();
|
const clientFileName = file.name.trim().toLowerCase();
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
|
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
||||||
|
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
|
||||||
if (li) {
|
if (li) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
overallSuccess = false;
|
overallSuccess = false;
|
||||||
|
|
||||||
} else if (li) {
|
} else if (li) {
|
||||||
|
succeeded++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
li.remove();
|
li.remove();
|
||||||
@@ -745,9 +788,12 @@ function submitFiles(allFiles) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!overallSuccess) {
|
if (!overallSuccess) {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
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.`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -756,6 +802,7 @@ function submitFiles(allFiles) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,4 +894,39 @@ function initUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initUpload };
|
export { initUpload };
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Clipboard Paste Handler (Mimics Drag-and-Drop)
|
||||||
|
// -------------------------
|
||||||
|
document.addEventListener('paste', function handlePasteUpload(e) {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
|
||||||
|
renamedFile.isClipboard = true;
|
||||||
|
|
||||||
|
Object.defineProperty(renamedFile, 'customRelativePath', {
|
||||||
|
value: renamedFile.name,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
files.push(renamedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
|
showToast('Pasted file added to upload list.', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
2
public/js/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// generated by CI
|
||||||
|
window.APP_VERSION = 'v1.6.8';
|
||||||
@@ -13,56 +13,62 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
||||||
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, loadUserPermissions(), etc.
|
||||||
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
||||||
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole()
|
||||||
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
|
require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig()
|
||||||
|
require_once __DIR__ . '/../src/lib/ACL.php'; // ACL checks
|
||||||
|
require_once __DIR__ . '/../src/webdav/CurrentUser.php';
|
||||||
|
|
||||||
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
||||||
$adminConfig = AdminModel::getConfig();
|
$adminConfig = AdminModel::getConfig();
|
||||||
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
||||||
if (!$enableWebDAV) {
|
if (!$enableWebDAV) {
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
echo 'WebDAV access is currently disabled by administrator.';
|
echo 'WebDAV access is currently disabled by administrator.';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 2) Load WebDAV directory implementation ──────────────────────────
|
// ─── 2) Load WebDAV directory implementation (ACL-aware) ────────────────────
|
||||||
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
||||||
|
|
||||||
use Sabre\DAV\Server;
|
use Sabre\DAV\Server;
|
||||||
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
||||||
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
||||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||||
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
||||||
use FileRise\WebDAV\FileRiseDirectory;
|
use FileRise\WebDAV\FileRiseDirectory;
|
||||||
|
use FileRise\WebDAV\CurrentUser;
|
||||||
|
|
||||||
// ─── 3) HTTP‑Basic backend ─────────────────────────────────────────────────
|
// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
|
||||||
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
||||||
return \AuthModel::authenticate($user, $pass) !== false;
|
return \AuthModel::authenticate($user, $pass) !== false;
|
||||||
});
|
});
|
||||||
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
||||||
|
|
||||||
// ─── 4) Determine user scope ────────────────────────────────────────────────
|
// ─── 4) Resolve authenticated user + perms ──────────────────────────────────
|
||||||
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
if ($user === '') {
|
||||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
header('WWW-Authenticate: Basic realm="FileRise"');
|
||||||
if ($isAdmin || !$folderOnly) {
|
echo 'Authentication required.';
|
||||||
// Admins (or users without folder-only restriction) see the full /uploads
|
exit;
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
} else {
|
|
||||||
// Folder‑only users see only /uploads/{username}
|
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
|
||||||
if (!is_dir($rootPath)) {
|
|
||||||
mkdir($rootPath, 0755, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
|
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
|
||||||
|
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||||
|
|
||||||
|
// set for metadata attribution in WebDAV writes
|
||||||
|
CurrentUser::set($user);
|
||||||
|
|
||||||
|
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
|
||||||
|
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
|
||||||
$server = new Server([
|
$server = new Server([
|
||||||
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
new FileRiseDirectory($rootPath, $user, $isAdmin, $perms),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auth + Locks
|
||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
$server->addPlugin(
|
$server->addPlugin(
|
||||||
new LocksPlugin(
|
new LocksPlugin(
|
||||||
@@ -70,5 +76,8 @@ $server->addPlugin(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Base URI (adjust if you serve from a subdir or rewrite rule)
|
||||||
$server->setBaseUri('/webdav.php/');
|
$server->setBaseUri('/webdav.php/');
|
||||||
|
|
||||||
|
// Execute
|
||||||
$server->exec();
|
$server->exec();
|
||||||
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 626 KiB After Width: | Height: | Size: 470 KiB |
BIN
resources/dark-folder-access.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 521 KiB |