Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3a7a5145 | ||
|
|
d00db803c3 | ||
|
|
77a94ecd85 | ||
|
|
699873848e | ||
|
|
9cb12c11a6 | ||
|
|
c08876380b | ||
|
|
5b824888cb | ||
|
|
b7d7f7c3ce | ||
|
|
e509b7ac9c | ||
|
|
947255d94c | ||
|
|
55d44ef880 | ||
|
|
ad76e37ad5 | ||
|
|
d664a2f5d8 | ||
|
|
a18a8df7af | ||
|
|
8cf5a34ae9 | ||
|
|
55d5656139 | ||
|
|
04be05ad1e | ||
|
|
0469d183de | ||
|
|
b1de8679e0 | ||
|
|
f4f7ec0dca | ||
|
|
5a7c4704d0 | ||
|
|
8b880738d6 | ||
|
|
06c732971f | ||
|
|
ab75381acb | ||
|
|
b1bd903072 | ||
|
|
ab327acc8a | ||
|
|
2e98ceee4c | ||
|
|
3351a11927 | ||
|
|
4dddcf0f99 | ||
|
|
35966964e7 | ||
|
|
7fe8e858ae | ||
|
|
64332211c9 | ||
|
|
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 |
@@ -12,3 +12,9 @@ tmp/
|
||||
.env
|
||||
.vscode/
|
||||
.DS_Store
|
||||
data/
|
||||
uploads/
|
||||
users/
|
||||
metadata/
|
||||
sessions/
|
||||
vendor/
|
||||
|
||||
44
.gitattributes
vendored
44
.gitattributes
vendored
@@ -1,4 +1,40 @@
|
||||
public/api.html linguist-documentation
|
||||
public/openapi.json linguist-documentation
|
||||
resources/ export-ignore
|
||||
.github/ export-ignore
|
||||
# --- Docs that shouldn't count toward code stats
|
||||
public/api.php linguist-documentation
|
||||
public/openapi.json linguist-documentation
|
||||
openapi.json.dist linguist-documentation
|
||||
SECURITY.md linguist-documentation
|
||||
CHANGELOG.md linguist-documentation
|
||||
CONTRIBUTING.md linguist-documentation
|
||||
CODE_OF_CONDUCT.md linguist-documentation
|
||||
LICENSE linguist-documentation
|
||||
README.md linguist-documentation
|
||||
|
||||
# --- Vendored/minified stuff: exclude from Linguist
|
||||
public/vendor/** linguist-vendored
|
||||
public/css/vendor/** linguist-vendored
|
||||
public/fonts/** linguist-vendored
|
||||
public/js/**/*.min.js linguist-vendored
|
||||
public/**/*.min.css linguist-vendored
|
||||
public/**/*.map linguist-generated
|
||||
|
||||
# --- Treat assets as binary (nicer diffs)
|
||||
*.png -diff
|
||||
*.jpg -diff
|
||||
*.jpeg -diff
|
||||
*.gif -diff
|
||||
*.webp -diff
|
||||
*.svg -diff
|
||||
*.ico -diff
|
||||
*.woff -diff
|
||||
*.woff2 -diff
|
||||
*.ttf -diff
|
||||
*.otf -diff
|
||||
*.zip -diff
|
||||
|
||||
# --- Keep these out of auto-generated source archives (OK to ignore)
|
||||
# Only ignore things you *never* need in release tarballs
|
||||
.github/ export-ignore
|
||||
resources/ export-ignore
|
||||
|
||||
# --- Normalize text files
|
||||
* text=auto
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
github: [error311]
|
||||
ko_fi: error311
|
||||
92
.github/workflows/ci.yml
vendored
Normal file
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
|
||||
204
.github/workflows/release-on-version.yml
vendored
Normal file
204
.github/workflows/release-on-version.yml
vendored
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: Release on version.js update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- public/js/version.js
|
||||
workflow_run:
|
||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||
types: [completed]
|
||||
|
||||
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: Ensure tags available
|
||||
run: |
|
||||
git fetch --tags --force --prune --quiet
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
# Ensure the stamper is executable and has LF endings (helps if edited on Windows)
|
||||
- name: Prep stamper script
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||
chmod +x scripts/stamp-assets.sh
|
||||
|
||||
- name: Build zip artifact (stamped)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
|
||||
# Clean staging copy (exclude dotfiles you don’t want)
|
||||
rm -rf staging
|
||||
rsync -a \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'resources' \
|
||||
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
|
||||
./ staging/
|
||||
|
||||
# Stamp IN THE STAGING COPY (invoke via bash to avoid exec-bit issues)
|
||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
||||
|
||||
- name: Verify placeholders are gone (staging)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT="$(pwd)/staging"
|
||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||
echo "---- DEBUG (show 10 hits with context) ----"
|
||||
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
|
||||
| head -n 10 | while IFS=: read -r file line _; do
|
||||
echo ">>> $file:$line"
|
||||
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
|
||||
echo "----------------------------------------"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: No unreplaced placeholders in staging."
|
||||
|
||||
- name: Zip stamped staging
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
(cd staging && zip -r "../$ZIP" . >/dev/null)
|
||||
|
||||
- name: Compute SHA-256 checksum
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: sum
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ZIP="FileRise-${{ steps.ver.outputs.version }}.zip"
|
||||
SHA=$(shasum -a 256 "$ZIP" | awk '{print $1}')
|
||||
echo "$SHA $ZIP" > "${ZIP}.sha256"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed SHA-256: $SHA"
|
||||
|
||||
- name: Extract notes from CHANGELOG (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 > CHANGELOG_SNIPPET.md || true
|
||||
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' CHANGELOG_SNIPPET.md || true
|
||||
if [[ -s CHANGELOG_SNIPPET.md ]]; then
|
||||
NOTES_PATH="CHANGELOG_SNIPPET.md"
|
||||
fi
|
||||
fi
|
||||
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute previous tag (for Full Changelog link)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: prev
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
PREV=$(git tag --list "v*" --sort=-v:refname | grep -v -F "$VER" | head -n1 || true)
|
||||
if [[ -z "$PREV" ]]; then
|
||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||
fi
|
||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||
echo "Previous tag or baseline: $PREV"
|
||||
|
||||
- name: Build release body (snippet + full changelog + checksum)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
PREV="${{ steps.prev.outputs.prev }}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
SHA="${{ steps.sum.outputs.sha }}"
|
||||
|
||||
{
|
||||
echo
|
||||
if [[ -s CHANGELOG_SNIPPET.md ]]; then
|
||||
cat CHANGELOG_SNIPPET.md
|
||||
echo
|
||||
fi
|
||||
echo "## ${VER}"
|
||||
echo "### Full Changelog"
|
||||
echo "[${PREV} → ${VER}](${COMPARE_URL})"
|
||||
echo
|
||||
echo "### SHA-256 (zip)"
|
||||
echo '```'
|
||||
echo "${SHA} ${ZIP}"
|
||||
echo '```'
|
||||
} > RELEASE_BODY.md
|
||||
|
||||
echo "Release body:"
|
||||
sed -n '1,200p' RELEASE_BODY.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.ver.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ steps.ver.outputs.version }}
|
||||
body_path: RELEASE_BODY.md
|
||||
generate_release_notes: false
|
||||
files: |
|
||||
FileRise-${{ steps.ver.outputs.version }}.zip
|
||||
FileRise-${{ steps.ver.outputs.version }}.zip.sha256
|
||||
92
.github/workflows/sync-changelog.yml
vendored
92
.github/workflows/sync-changelog.yml
vendored
@@ -1,43 +1,115 @@
|
||||
name: Sync Changelog to Docker Repo
|
||||
---
|
||||
name: Bump version and sync Changelog to Docker Repo
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'CHANGELOG.md'
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: bump-and-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
bump_and_sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout FileRise
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: file-rise
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Extract version from commit message
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
# Ensure we're on the branch and up to date BEFORE modifying files
|
||||
- name: Ensure clean branch (no local mods), update from remote
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Be on a named branch that tracks the remote
|
||||
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
|
||||
# Make sure the worktree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "::error::Working tree not clean before update. Aborting."
|
||||
git status --porcelain
|
||||
exit 1
|
||||
fi
|
||||
# Update branch
|
||||
git pull --rebase origin "${{ github.ref_name }}"
|
||||
|
||||
- name: Update public/js/version.js (source of truth)
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > public/js/version.js <<'EOF'
|
||||
// generated by CI
|
||||
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
||||
EOF
|
||||
|
||||
- name: Commit version.js only
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
|
||||
git push origin "${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
- name: Checkout filerise-docker
|
||||
if: steps.ver.outputs.version != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: error311/filerise-docker
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
path: docker-repo
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy CHANGELOG.md
|
||||
- name: Copy CHANGELOG.md and write VERSION
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
|
||||
set -euo pipefail
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
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
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
||||
git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||
git push origin main
|
||||
fi
|
||||
fi
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/data/
|
||||
949
CHANGELOG.md
949
CHANGELOG.md
@@ -1,5 +1,944 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/3/2025 (v1.8.0)
|
||||
|
||||
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
|
||||
|
||||
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
|
||||
|
||||
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
|
||||
|
||||
- **Backend / API**
|
||||
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
|
||||
- New endpoints:
|
||||
- GET /api/onlyoffice/status.php — reports availability + supported exts.
|
||||
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
|
||||
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
|
||||
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
|
||||
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
|
||||
|
||||
- **Admin config / UI**
|
||||
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
|
||||
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
|
||||
|
||||
- **Frontend integration**
|
||||
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
|
||||
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
|
||||
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
|
||||
|
||||
- **AdminModel / config**
|
||||
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
|
||||
- Optional constants in config.php to override and debug.
|
||||
|
||||
- **Security & UX notes**
|
||||
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
|
||||
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
|
||||
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
|
||||
|
||||
- **Docs/Styling**
|
||||
- Minor CSS touch-ups around hover states and modal layout.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/2/2025 (v1.7.5)
|
||||
|
||||
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
|
||||
release(v1.7.5): retrigger CI bump (no code changes)
|
||||
release(v1.7.5): retrigger CI bump ensure up to date
|
||||
|
||||
### Security/headers
|
||||
|
||||
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
|
||||
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
|
||||
|
||||
### Previews & editor
|
||||
|
||||
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
|
||||
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
|
||||
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
|
||||
|
||||
### Login, theming & UX polish
|
||||
|
||||
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
|
||||
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
|
||||
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
|
||||
|
||||
### HTML/CSS & perf
|
||||
|
||||
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
|
||||
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
|
||||
- Header logo marked `fetchpriority="high"` for faster first paint.
|
||||
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
|
||||
|
||||
### Manual Deploy script
|
||||
|
||||
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
|
||||
|
||||
### Notes
|
||||
|
||||
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/31/2025 (v1.7.4)
|
||||
|
||||
release(v1.7.4): login hint replace toast + fix unauth boot
|
||||
|
||||
main.js
|
||||
|
||||
- Added isDemoHost() and showLoginTip(message).
|
||||
- In the unauth branch, call showLoginTip('Please log in to continue').
|
||||
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/31/2025 (v1.7.3)
|
||||
|
||||
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
|
||||
|
||||
### 🎃 Highlights (advantages) 👻 🦇
|
||||
|
||||
- ⚡ Faster, cleaner boot: a lightweight **main.js** decides auth/setup before painting, avoids flicker, and wires modules exactly once.
|
||||
- ♻️ Fewer duplicate actions: **request coalescer** dedupes POST/PUT/PATCH/DELETE to /api/* .
|
||||
- ✅ Truthy UX: global **toast bridge** queues early toasts and normalizes misleading “not found/already exists” messages after success.
|
||||
- 🔐 Smoother auth: CSRF priming/rotation + **TOTP step-up detection** across JSON & redirect paths; “Welcome back, `user`” toast once per tab.
|
||||
- 🌓 Polished UI: **dark-mode persistence with system fallback**, live siteConfig title application, higher-z modals, drag auto-scroll.
|
||||
- 🚀 Faster first paint & interactions: defer CodeMirror/Fuse/Resumable, promote preloaded CSS, and coalesce duplicate requests → snappier UI.
|
||||
- 🧭 Admin polish: live header title preview, masked OIDC fields with **Replace** flow, and a **read-only Sponsors/Donations** section.
|
||||
- 🧱 Safer & cache-smarter: opinionated .htaccess (CSP/HSTS/MIME/compression) + `?v={{APP_QVER}}` for versioned immutable assets.
|
||||
|
||||
### Core bootstrap (main.js) overhaul
|
||||
|
||||
- Early **toast bridge** (queues until domUtils is ready); expose `window.__FR_TOAST_FILTER__` for centralized rewrites/suppression.
|
||||
- **Result guard + request coalescer** wrapping `fetch`:
|
||||
- Dedupes same-origin `/api/*` mutating requests for ~800ms using a stable key (method + path + normalized body).
|
||||
- Tracks “last OK” JSON (`success|status|result=ok`) to suppress false-negative error toasts after success.
|
||||
- **Boot orchestrator** with hard guards:
|
||||
- `__FR_FLAGS` (`booted`, `initialized`, `wired.*`, `bootPromise`, `entryStarted`) to prevent double init/leaks.
|
||||
- **No-flicker login**: resolve `checkAuth()` + `setup` before showing UI; show login only when truly unauthenticated.
|
||||
- **Heavy boot** for authed users: load i18n, `appCore.loadCsrfToken/initializeApp`, first file list, then light UI wiring.
|
||||
- **Auth flow**:
|
||||
- `primeCsrf()` + `<meta name="csrf-token">` management; persist token in localStorage.
|
||||
- **TOTP** detection via header (`X-TOTP-Required`) & JSON (`totp_required` / `TOTP_REQUIRED`); calls `openTOTPLoginModal()`.
|
||||
- **Welcome toast** once per tab via `sessionStorage.__fr_welcomed`.
|
||||
- **UI/UX niceties**:
|
||||
- `applySiteConfig()` updates header title & login method visibility on both login & authed screens.
|
||||
- Dark-mode persistence with system fallback, proper a11y labels/icons.
|
||||
- Create dropdown/menu wiring with capture-phase outside-click + ESC close; modal cancel safeties.
|
||||
- Lift modals above cards (z-index), **drag auto-scroll** near viewport edges.
|
||||
- Dispatch legacy `DOMContentLoaded`/`load` **once** (supports older inline handlers).
|
||||
- Username label refresh for existing `.user-name-label` without injecting new DOM.
|
||||
|
||||
### Performance & UX changes
|
||||
|
||||
- CSS/first paint:
|
||||
- Preload Bootstrap & app CSS; promote at DOMContentLoaded; keep inline CSS minimal.
|
||||
- Add `width/height/decoding/fetchpriority` to logo to reduce layout shift.
|
||||
- Search/editor/uploads:
|
||||
- **fileListView.js**: lazy-load Fuse with instant substring fallback; `warmUpSearch()` hook.
|
||||
- **fileEditor.js**: lazy-load CodeMirror core/theme/modes; start plain then upgrade; guard very large files gracefully.
|
||||
- **upload.js**: lazy-load Resumable; resilient init; background warm-up; smarter addFile/submit; clearer toasts.
|
||||
- Toast/UX:
|
||||
- Install early toast bridge; queue & normalize messages; neutral “Done.” when server returns misleading errors after success.
|
||||
|
||||
### Correctness: uploads, paths, ACLs
|
||||
|
||||
- **UploadController/UploadModel**: normalize folders via `ACL::normalizeFolder(rawurldecode())`; stricter segment checks; consistent base paths; safer metadata writes; proper chunk presence/merge & temp cleanup.
|
||||
|
||||
### Auth hardening & resilience
|
||||
|
||||
- **auth.js/main.js/appCore.js**: CSRF rotate/retry (JSON then x-www-form-urlencoded fallback); robust login handling; fewer misleading error toasts.
|
||||
- **AuthController**: OIDC username fallback to `email` or `sub` when `preferred_username` missing.
|
||||
|
||||
### Admin panel
|
||||
|
||||
- **adminPanel.js**:
|
||||
- Live header title preview (instant update without reload).
|
||||
- Masked OIDC client fields with **Replace** button; saved-value hints; only send secrets when replacing.
|
||||
- **New “Sponsor / Donations” section (read-only)**:
|
||||
- GitHub Sponsors → `https://github.com/sponsors/error311`
|
||||
- Ko-fi → `https://ko-fi.com/error311`
|
||||
- Includes **Copy** and **Open** buttons; values are fixed.
|
||||
- **AdminController**: boolean for `oidc.hasClientId/hasClientSecret` to drive masked inputs.
|
||||
|
||||
### Security & caching (.htaccess)
|
||||
|
||||
- Consolidated security headers (CSP, CORP, HSTS on HTTPS), MIME types, compression (Brotli/Deflate), TRACE disable.
|
||||
- Caching rules:
|
||||
- HTML/version.js: no-cache; unversioned JS/CSS: 1h; unversioned static: 7d; **versioned assets `?v=`: 1y `immutable`**.
|
||||
- **config.php**: remove duplicate runtime headers (now via Apache) to avoid proxy/CDN conflicts.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No schema changes.
|
||||
- Ensure Apache modules (`headers`, `rewrite`, `brotli`/`deflate`) are available for the new .htaccess rules (fallbacks included).
|
||||
- Versioned assets mean users shouldn’t need a hard refresh; `?v={{APP_QVER}}` busts caches automatically.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/29/2025 (v1.7.0 & v1.7.1 & v1.7.2)
|
||||
|
||||
release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- Public, non-sensitive site config cache:
|
||||
- Add `AdminModel::buildPublicSubset()` and `writeSiteConfig()` to write `USERS_DIR/siteConfig.json`.
|
||||
- New endpoint `public/api/siteConfig.php` + `UserController::siteConfig()` to serve the public subset (regenerates if stale).
|
||||
- Frontend now reads `/api/siteConfig.php` (safe subset) instead of `/api/admin/getConfig.php`.
|
||||
- Frontend module versioning:
|
||||
- Replace all module imports with `?v={{APP_QVER}}` query param so the release/Docker stamper can pin exact versions.
|
||||
- Add `scripts/stamp-assets.sh` to stamp `?v=` and `{{APP_VER}}/{{APP_QVER}}` in **staging** for ZIP/Docker builds.
|
||||
|
||||
### 🧩 Refactors
|
||||
|
||||
- Extract shared boot/bootstrap logic into `public/js/appCore.js`:
|
||||
- CSRF helpers (`setCsrfToken`, `getCsrfToken`, `loadCsrfToken`)
|
||||
- `initializeApp()`, `triggerLogout()`
|
||||
- Keep `main.js` lean; wrap global `fetch` once to append/rotate CSRF.
|
||||
- Update imports across JS modules to use versioned module URLs.
|
||||
|
||||
### 🚀 Performance
|
||||
|
||||
- Aggressive, safe caching for versioned assets:
|
||||
- `.htaccess`: `?v=…` ⇒ `Cache-Control: max-age=31536000, immutable`.
|
||||
- Unversioned JS/CSS short cache (1h), other static (7d).
|
||||
- Eliminate duplicate `main.js` loads and tighten CodeMirror mode loading.
|
||||
|
||||
### 🔒 Security / Hardening
|
||||
|
||||
- `.htaccess`:
|
||||
- Conditional HSTS only when HTTPS, add CORP and X-Permitted-Cross-Domain-Policies.
|
||||
- CSP kept strict for modules, workers, blobs.
|
||||
- Admin config exposure reduced to a curated subset in `siteConfig.json`.
|
||||
|
||||
### 🧪 CI/CD / Release
|
||||
|
||||
- **FileRise repo**
|
||||
- `sync-changelog.yml`: keep `public/js/version.js` as source-of-truth only (no repo-wide stamping).
|
||||
- `release-on-version.yml`: build **stamped** ZIP from a staging copy via `scripts/stamp-assets.sh`, verify placeholders removed, attach checksum.
|
||||
- **filerise-docker repo**
|
||||
- Read `VERSION`, checkout app to `app/`, run stamper inside build context before `docker buildx`, tag `latest` and `:${VERSION}`.
|
||||
|
||||
### 🔧 Defaults
|
||||
|
||||
- Sample/admin config defaults now set `disableBasicAuth: true` (safer default). Existing installations keep their current setting.
|
||||
|
||||
### 📂 Notable file changes
|
||||
|
||||
- `src/models/AdminModel.php` (+public subset +atomic write)
|
||||
- `src/controllers/UserController.php` (+siteConfig action)
|
||||
- `public/api/siteConfig.php` (new)
|
||||
- `public/js/appCore.js` (new), `public/js/main.js` (slim, uses appCore)
|
||||
- Many `public/js/*.js` import paths updated to `?v={{APP_QVER}}`
|
||||
- `public/.htaccess` (caching & headers)
|
||||
- `scripts/stamp-assets.sh` (new)
|
||||
|
||||
### ⚠️ Upgrade notes
|
||||
|
||||
- Ensure `USERS_DIR` is writable by web server for `siteConfig.json`.
|
||||
- Proxies/edge caches: the new `?v=` scheme enables long-lived immutable caching; purge is automatic on version bump.
|
||||
- If you previously read admin config directly on the client, it now reads `/api/siteConfig.php`.
|
||||
|
||||
### Additional changes/fixes for release
|
||||
|
||||
- `release-on-version.yml`
|
||||
- normalize line endings (strip CRLF)
|
||||
- stamp-assets.sh don’t rely on the exec; invoke via bash
|
||||
|
||||
release(v1.7.2): harden asset stamping & CI verification
|
||||
|
||||
### build(stamper)
|
||||
|
||||
- Rewrite scripts/stamp-assets.sh to be repo-agnostic and macOS/Windows friendly:
|
||||
- Drop reliance on git ls-files/mapfile; use find + null-delimited loops
|
||||
- Normalize CRLF to LF for all web assets before stamping
|
||||
- Stamp ?v=<APP_QVER> in HTML/CSS/PHP and {{APP_VER}} everywhere
|
||||
- Normalize any ".mjs|.js?v=..." occurrences inside JS (ESM imports/strings)
|
||||
- Force-write public/js/version.js from VER (source of truth in stamped output)
|
||||
- Print touched counts and fail fast if any {{APP_QVER}}|{{APP_VER}} remain
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/28/2025 (v1.6.11)
|
||||
|
||||
release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action
|
||||
|
||||
Re-add the click handler to toggle `zonesCollapsed` so the header
|
||||
“sidebarToggleFloating” button actually expands/collapses the zones
|
||||
again. This regressed in v1.6.10 during auth-gating refactor.
|
||||
|
||||
Refs: #regression #ux
|
||||
|
||||
chore(codeql): move config to repo root for default setup
|
||||
|
||||
- Relocate .github/codeql/codeql-config.yml to codeql-config.yml so GitHub default code scanning picks it up
|
||||
- Keep paths: public/js, api
|
||||
- Keep ignores: public/vendor/**, public/css/vendor/**, public/fonts/**, public/**/*.min.{js,css}, public/**/*.map
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/28/2025 (v1.6.10)
|
||||
|
||||
release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow
|
||||
|
||||
- Vendor ReDoc and add MIT license file under public/vendor/redoc/; switch api.php to local bundle to satisfy CSP (script-src 'self').
|
||||
- main.js: add/remove body.authenticated on login/logout so UI can reflect auth state.
|
||||
- dragAndDrop.js: only render sidebarToggleFloating when authenticated; stop event bubbling, keep dark-mode styles.
|
||||
- sync-changelog.yml: also stamp ?v= in PHP templates (public/**/*.php).
|
||||
- release-on-version.yml: build zip first, compute SHA-256, assemble release body with latest CHANGELOG snippet, “Full Changelog” compare link, and attach .sha256 alongside the zip.
|
||||
- THIRD_PARTY.md: document ReDoc vendoring and rationale.
|
||||
|
||||
Refs: #security #csp #release
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/27/2025 (v1.6.9)
|
||||
|
||||
release(v1.6.9): feat(core) localize assets, harden headers, and speed up load
|
||||
|
||||
- index.html: drop all CDNs in favor of local /vendor assets
|
||||
- add versioned cache-busting query (?v=…) on CSS/JS
|
||||
- wire version.js for APP_VERSION and numeric cache key
|
||||
- public/vendor/: add pinned copies of:
|
||||
- bootstrap 4.5.2, codemirror 5.65.5 (+ themes/modes), dompurify 2.4.0,
|
||||
fuse.js 6.6.2, resumable.js 1.1.0
|
||||
- fonts: add self-hosted Material Icons + Roboto (latin + latin-ext) with
|
||||
vendor CSS (material-icons.css, roboto.css)
|
||||
|
||||
- fileEditor.js: load CodeMirror modes from local vendor with ?v=APP_VERSION_NUM,
|
||||
keep timeout/plain-text fallback, no SRI (same-origin)
|
||||
- dragAndDrop.js: nudge zonesToggle 65px left to sit tighter to the logo
|
||||
|
||||
- styles.css: prune/organize rules and add small utility classes; move 3P
|
||||
font CSS to /css/vendor/
|
||||
|
||||
- .htaccess: security + performance overhaul
|
||||
- Content-Security-Policy: default-src 'self'; img-src include data: and blob:
|
||||
- version-aware caching: HTML/version.js = no-cache; assets with ?v= = 1y immutable
|
||||
- correct MIME for fonts/SVG; enable Brotli/Gzip (if available)
|
||||
- X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy
|
||||
- disable TRACE; deny dotfiles; prevent directory listing
|
||||
|
||||
- .gitattributes: mark vendor/minified as linguist-vendored, treat assets as
|
||||
binary in diffs, exclude CI/resources from source archives
|
||||
|
||||
- docs/licensing:
|
||||
- add licenses/ and THIRD_PARTY.md with upstream licenses/attribution
|
||||
- README: add “License & Credits” section with components and licenses
|
||||
|
||||
- CI: (sync-changelog) stamp asset cache-busters to the numeric release
|
||||
(e.g. ?v=1.6.9) and write window.APP_VERSION in version.js before Docker build
|
||||
|
||||
perf: site loads significantly faster with local assets + compression + long-lived caching
|
||||
security: CSP, strict headers, and same-origin assets reduce XSS/SRI/CORS risk
|
||||
|
||||
Refs: #performance #security
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -93,7 +1032,7 @@
|
||||
|
||||
- **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`.
|
||||
- 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`)
|
||||
@@ -141,7 +1080,7 @@
|
||||
|
||||
- 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`
|
||||
- **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`).
|
||||
@@ -262,7 +1201,7 @@
|
||||
- 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:
|
||||
- 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
|
||||
|
||||
@@ -304,7 +1243,7 @@
|
||||
- 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
|
||||
### Security old
|
||||
|
||||
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||
|
||||
@@ -333,7 +1272,7 @@
|
||||
|
||||
- **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).
|
||||
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
||||
|
||||
423
README.md
423
README.md
@@ -1,11 +1,33 @@
|
||||
# FileRise
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
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://github.com/error311/FileRise)
|
||||
[](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) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [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.
|
||||
|
||||
New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If 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:**
|
||||

|
||||
@@ -14,152 +36,284 @@ 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)
|
||||
|
||||
- 🚀 **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.
|
||||
- - 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
|
||||
|
||||
- ⚙️ **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.
|
||||
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||
|
||||
(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.)
|
||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||
|
||||
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
|
||||
|
||||
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
|
||||
|
||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
|
||||
|
||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- **Run a container:**
|
||||
#### Run a container
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e PUID="1000" \
|
||||
-e PGID="1000" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e SHARE_URL="" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
--name filerise \
|
||||
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:**
|
||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||
|
||||
``` yaml
|
||||
version: '3'
|
||||
**Notes**
|
||||
|
||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
|
||||
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
||||
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
|
||||
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||
|
||||
**Verify ownership mapping (optional)**
|
||||
|
||||
```bash
|
||||
docker exec -it filerise id www-data
|
||||
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "UTC"
|
||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
# Ownership & indexing
|
||||
PUID: "1000" # Unraid users often use 99
|
||||
PGID: "1000" # Unraid users often use 100
|
||||
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||
SHARE_URL: ""
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
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!
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
# Linux (GVFS/GIO)
|
||||
gio mount dav://demo@your-host/webdav.php/
|
||||
|
||||
# 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)
|
||||
|
||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
||||
@@ -170,8 +324,8 @@ dav://demo@your-host/webdav.php/
|
||||
https://your-host/webdav.php/
|
||||
```
|
||||
|
||||
- Check **Connect using different credentials**, and enter your FileRise username and password.
|
||||
- Click **Finish**. The drive will now appear under **This PC**.
|
||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||
- Click **Finish**.
|
||||
|
||||
> **Important:**
|
||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||
@@ -186,33 +340,94 @@ dav://demo@your-host/webdav.php/
|
||||
>
|
||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## Quick start: ONLYOFFICE (optional)
|
||||
|
||||
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
|
||||
|
||||
**What you need**
|
||||
|
||||
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
|
||||
- A shared **JWT secret** used by FileRise and your Document Server.
|
||||
|
||||
**Setup (2–3 minutes)**
|
||||
|
||||
1. In FileRise go to **Admin → ONLYOFFICE** and:
|
||||
- ✅ Enable ONLYOFFICE
|
||||
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
|
||||
- 🔑 Enter **JWT Secret** (click “Replace” to set)
|
||||
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
|
||||
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
|
||||
3. Update your **Content-Security-Policy** to allow the DS origin.
|
||||
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
|
||||
|
||||
**Apache**
|
||||
|
||||
```apache
|
||||
Header always set Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'"
|
||||
```
|
||||
|
||||
**Nginx**
|
||||
|
||||
```add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'" always;
|
||||
```
|
||||
|
||||
**Notes**
|
||||
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
|
||||
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
|
||||
|
||||
---
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
- **“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.
|
||||
- **ONLYOFFICE editor won’t load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
|
||||
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
|
||||
|
||||
- **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.
|
||||
- **“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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
For more Q&A or to ask for help, please check the Discussions or open an issue.
|
||||
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
|
||||
|
||||
For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
---
|
||||
|
||||
## Security posture
|
||||
|
||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||
If you’re running ≤1.4.x, please upgrade.
|
||||
|
||||
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! 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!
|
||||
|
||||
---
|
||||
|
||||
@@ -220,12 +435,28 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
||||
|
||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||
- **GitHub Discussions:** Use 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)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
||||
|
||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
|
||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||
|
||||
#### Security / CSP
|
||||
|
||||
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
@@ -251,6 +482,10 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
## License & Credits
|
||||
|
||||
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).
|
||||
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
|
||||
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
|
||||
|
||||
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
|
||||
|
||||
64
SECURITY.md
64
SECURITY.md
@@ -2,32 +2,60 @@
|
||||
|
||||
## 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
|
||||
|
||||
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:**
|
||||
Send an email to [security@filerise.net](mailto:security@filerise.net) with the subject line “[FileRise] Security Vulnerability Report”.
|
||||
1) **GitHub Security Advisory (preferred)**
|
||||
Open a private report here: <https://github.com/error311/FileRise/security/advisories/new>
|
||||
|
||||
2. **Include Details:**
|
||||
Provide a detailed description of the vulnerability, steps to reproduce it, and any other relevant information (e.g., affected versions, screenshots, logs).
|
||||
2) **Email**
|
||||
Send details to **<security@filerise.net>** with subject: `[FileRise] Security Vulnerability Report`.
|
||||
|
||||
3. **Secure Communication (Optional):**
|
||||
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.
|
||||
### What to include
|
||||
|
||||
## 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:**
|
||||
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.
|
||||
If you’d like encrypted comms, ask for our PGP key in your first email.
|
||||
|
||||
- **Public Disclosure:**
|
||||
After a fix is available, details of the vulnerability will be disclosed publicly in a way that does not compromise user security.
|
||||
## Coordinated Disclosure
|
||||
|
||||
## 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>**
|
||||
|
||||
47
THIRD_PARTY.md
Normal file
47
THIRD_PARTY.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Third-Party Notices
|
||||
|
||||
FileRise bundles the following third‑party assets. Each item lists the project, version, typical on-disk location in this repo, and its license.
|
||||
|
||||
If you believe any attribution is missing or incorrect, please open an issue.
|
||||
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
- **Roboto (wght 400/500)** — Google Fonts
|
||||
**License:** Apache License 2.0
|
||||
**Files:** `public/css/vendor/roboto.css`, `public/fonts/roboto/*.woff2`
|
||||
|
||||
- **Material Icons (ligature font)** — Google Fonts
|
||||
**License:** Apache License 2.0
|
||||
**Files:** `public/css/vendor/material-icons.css`, `public/fonts/material-icons/*.woff2`
|
||||
|
||||
> Google fonts/icons © Google. Licensed under Apache 2.0. See `licenses/apache-2.0.txt`.
|
||||
|
||||
---
|
||||
|
||||
## CSS / JS Libraries (vendored)
|
||||
|
||||
- **Bootstrap 4.5.2** — MIT License
|
||||
**Files:** `public/vendor/bootstrap/4.5.2/bootstrap.min.css`
|
||||
|
||||
- **CodeMirror 5.65.5** — MIT License
|
||||
**Files:** `public/vendor/codemirror/5.65.5/*`
|
||||
|
||||
- **DOMPurify 2.4.0** — Apache License 2.0
|
||||
**Files:** `public/vendor/dompurify/2.4.0/purify.min.js`
|
||||
|
||||
- **Fuse.js 6.6.2** — Apache License 2.0
|
||||
**Files:** `public/vendor/fuse/6.6.2/fuse.min.js`
|
||||
|
||||
- **Resumable.js 1.1.0** — MIT License
|
||||
**Files:** `public/vendor/resumable/1.1.0/resumable.min.js`
|
||||
|
||||
- **ReDoc (redoc.standalone.js)** — MIT License
|
||||
**Files:** `public/vendor/redoc/redoc.standalone.js`
|
||||
**Notes:** Self-hosted to comply with `script-src 'self'` CSP.
|
||||
|
||||
> MIT-licensed code: see `licenses/mit.txt`.
|
||||
> Apache-2.0–licensed code: see `licenses/apache-2.0.txt`.
|
||||
|
||||
---
|
||||
12
codeql-config.yml
Normal file
12
codeql-config.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: FileRise CodeQL config
|
||||
paths:
|
||||
- public/js
|
||||
- api
|
||||
paths-ignore:
|
||||
- public/vendor/**
|
||||
- public/css/vendor/**
|
||||
- public/fonts/**
|
||||
- public/**/*.min.js
|
||||
- public/**/*.min.css
|
||||
- public/**/*.map
|
||||
@@ -1,22 +1,6 @@
|
||||
<?php
|
||||
// config.php
|
||||
|
||||
// Prevent caching
|
||||
header("Cache-Control: no-cache, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Expires: 0");
|
||||
|
||||
// Security headers
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header("X-Frame-Options: SAMEORIGIN");
|
||||
header("Referrer-Policy: no-referrer-when-downgrade");
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
|
||||
}
|
||||
|
||||
// Define constants
|
||||
define('PROJECT_ROOT', dirname(__DIR__));
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
@@ -35,6 +19,19 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
|
||||
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);
|
||||
// ONLYOFFICE integration overrides (uncomment and set as needed)
|
||||
/*
|
||||
define('ONLYOFFICE_ENABLED', false);
|
||||
define('ONLYOFFICE_JWT_SECRET', 'test123456');
|
||||
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
@@ -70,16 +67,27 @@ function loadUserPermissions($username)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
$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;
|
||||
}
|
||||
if (!file_exists($permissionsFile)) {
|
||||
return 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
|
||||
@@ -89,25 +97,39 @@ $secure = ($envSecure !== false)
|
||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
// Choose session lifetime based on "remember me" cookie
|
||||
$defaultSession = 7200; // 2 hours
|
||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||
$sessionLifetime = isset($_COOKIE['remember_me_token'])
|
||||
? $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);
|
||||
$defaultSession = 7200; // 2 hours
|
||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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();
|
||||
} 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
|
||||
@@ -115,8 +137,7 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
$_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'])) {
|
||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$tokens = [];
|
||||
@@ -196,13 +217,21 @@ if (AUTH_BYPASS) {
|
||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||
}
|
||||
}
|
||||
// Share URL fallback
|
||||
|
||||
// Share URL fallback (keep BASE_URL behavior)
|
||||
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) {
|
||||
$defaultShare = isset($_SERVER['HTTP_HOST'])
|
||||
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
|
||||
: "http://localhost/api/file/share.php";
|
||||
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||
} else {
|
||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||
}
|
||||
|
||||
// Final: env var wins, else fallback
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
43
docker-compose.yml
Normal file
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
|
||||
5
licenses/NOTICE_GOOGLE_FONTS.txt
Normal file
5
licenses/NOTICE_GOOGLE_FONTS.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Google Fonts & Icons NOTICE
|
||||
|
||||
This product bundles font files from Google Fonts (Roboto, Material Icons, and/or Material Symbols).
|
||||
Copyright 2012–present Google Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (see ../apache-2.0.txt).
|
||||
202
licenses/apache-2.0.txt
Normal file
202
licenses/apache-2.0.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
19
licenses/mit.txt
Normal file
19
licenses/mit.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
5098
openapi.json.dist
5098
openapi.json.dist
File diff suppressed because it is too large
Load Diff
135
public/.htaccess
135
public/.htaccess
@@ -1,75 +1,112 @@
|
||||
# -----------------------------
|
||||
# 1) Prevent directory listings
|
||||
# -----------------------------
|
||||
# --------------------------------
|
||||
# FileRise portable .htaccess
|
||||
# --------------------------------
|
||||
Options -Indexes
|
||||
|
||||
# -----------------------------
|
||||
# Default index files
|
||||
# -----------------------------
|
||||
DirectoryIndex index.html
|
||||
|
||||
# -----------------------------
|
||||
# Deny access to hidden files
|
||||
# -----------------------------
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known
|
||||
<FilesMatch "^\.(?!well-known(?:/|$))">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Enforce HTTPS (optional)
|
||||
# -----------------------------
|
||||
# ---------------- Rewrites ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
#RewriteCond %{HTTPS} off
|
||||
|
||||
# Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
|
||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
RewriteRule - - [L]
|
||||
|
||||
# HTTPS redirect (enable ONE of these, comment the other)
|
||||
|
||||
# A) Direct TLS on this server
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Allow requests from a specific origin
|
||||
#Header set Access-Control-Allow-Origin "https://demo.filerise.net"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
|
||||
Header set Access-Control-Allow-Credentials "true"
|
||||
# B) Behind reverse proxy that sets X-Forwarded-Proto
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Mark versioned assets (?v=...) with env flag for caching rules below
|
||||
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||
RewriteRule ^ - [E=IS_VER:1]
|
||||
</IfModule>
|
||||
|
||||
# ---------------- MIME types ----------------
|
||||
<IfModule mod_mime.c>
|
||||
AddType font/woff2 .woff2
|
||||
AddType font/woff .woff
|
||||
AddType image/svg+xml .svg
|
||||
AddType application/javascript .mjs
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Security headers ----------------
|
||||
<IfModule mod_headers.c>
|
||||
# Prevent clickjacking
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
# Block XSS
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
# No MIME sniffing
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
Header always set X-Download-Options "noopen"
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||
Header always set X-Permitted-Cross-Domain-Policies "none"
|
||||
|
||||
# HSTS only when HTTPS (safe for .htaccess)
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
|
||||
|
||||
# CSP — keep this SHA-256 in sync with your inline pre-theme script
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Caching ----------------
|
||||
<IfModule mod_headers.c>
|
||||
# HTML: always revalidate
|
||||
<FilesMatch "\.(html|htm)$">
|
||||
# HTML/PHP: no cache
|
||||
<FilesMatch "\.(html?|php)$">
|
||||
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header setifempty Pragma "no-cache"
|
||||
Header setifempty Expires "0"
|
||||
</FilesMatch>
|
||||
|
||||
# version.js: never cache
|
||||
<FilesMatch "^js/version\.js$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
# JS/CSS: short‑term cache, revalidate regularly
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||
|
||||
# JS/CSS: long cache if ?v= present, else 1h
|
||||
<FilesMatch "\.(?:m?js|css)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
|
||||
</FilesMatch>
|
||||
|
||||
# Images/fonts: long cache if ?v= present, else 7d
|
||||
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=604800" env=!IS_VER
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Additional Security Headers
|
||||
# -----------------------------
|
||||
<IfModule mod_headers.c>
|
||||
# Enforce HTTPS for a year with subdomains and preload option.
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# Set a Referrer Policy.
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Permissions Policy: disable features you don't need.
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
# IE-specific header to prevent downloads from opening in IE.
|
||||
Header always set X-Download-Options "noopen"
|
||||
# Expect-CT header for Certificate Transparency (optional).
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
# ---------------- Compression ----------------
|
||||
<IfModule mod_brotli.c>
|
||||
# Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only)
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Disable TRACE method
|
||||
# -----------------------------
|
||||
# ---------------- Disable TRACE ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||
RewriteRule .* - [F]
|
||||
RewriteRule .* - [F]
|
||||
</IfModule>
|
||||
@@ -19,13 +19,11 @@ if (isset($_GET['spec'])) {
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FileRise API Docs</title>
|
||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
|
||||
crossorigin="anonymous"></script>
|
||||
<script defer src="/js/redoc-init.js"></script>
|
||||
<script defer src="/vendor/redoc/redoc.standalone.js?v={{APP_QVER}}"></script>
|
||||
<script defer src="/js/redoc-init.js?v={{APP_QVER}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="api.php?spec=1"></redoc>
|
||||
<redoc spec-url="/api.php?spec=1"></redoc>
|
||||
<div id="redoc-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,40 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
85
public/api/admin/acl/getGrants.php
Normal file
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
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
|
||||
// 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 PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
<?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';
|
||||
|
||||
// Only admins may read these
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
<?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 PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<?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';
|
||||
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
|
||||
245
public/api/folder/capabilities.php
Normal file
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
|
||||
// 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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
9
public/api/folder/moveFolder.php
Normal file
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
|
||||
// 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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
13
public/api/onlyoffice/callback.php
Normal file
13
public/api/onlyoffice/callback.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/onlyoffice/callback.php",
|
||||
* summary="ONLYOFFICE save callback",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="OK / error JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->callback();
|
||||
17
public/api/onlyoffice/config.php
Normal file
17
public/api/onlyoffice/config.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/config.php",
|
||||
* summary="Get editor config for a file (signed URLs, callback)",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="Editor config"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=404, description="Disabled / Not found")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->config();
|
||||
15
public/api/onlyoffice/signed-download.php
Normal file
15
public/api/onlyoffice/signed-download.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/signed-download.php",
|
||||
* summary="Serve a signed file blob to ONLYOFFICE",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="File stream"),
|
||||
* @OA\Response(response=403, description="Signature/expiry invalid")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->signedDownload();
|
||||
13
public/api/onlyoffice/status.php
Normal file
13
public/api/onlyoffice/status.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/status.php",
|
||||
* summary="ONLYOFFICE availability & supported extensions",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="Status JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->status();
|
||||
@@ -1,4 +1,29 @@
|
||||
<?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';
|
||||
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
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');
|
||||
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
9
public/api/siteConfig.php
Normal file
9
public/api/siteConfig.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
// public/api/siteConfig.php
|
||||
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->siteConfig();
|
||||
@@ -1,6 +1,32 @@
|
||||
<?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 PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
<?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 PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
<?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 PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||
|
||||
|
||||
@@ -1,5 +1,84 @@
|
||||
<?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 PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
24
public/css/vendor/material-icons.css
vendored
Normal file
24
public/css/vendor/material-icons.css
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/* fallback */
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
44
public/css/vendor/roboto.css
vendored
Normal file
44
public/css/vendor/roboto.css
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Roboto Regular 400 — latin-ext */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:400;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
/* Roboto Regular 400 — latin */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:400;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
/* Roboto Medium 500 — latin-ext */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:500;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
/* Roboto Medium 500 — latin */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:500;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
/* sensible stack so Chinese falls back cleanly */
|
||||
:root{
|
||||
--ui-font: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
body{ font-family: var(--ui-font); }
|
||||
BIN
public/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2
vendored
Normal file
BIN
public/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2
vendored
Normal file
Binary file not shown.
BIN
public/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2
vendored
Normal file
BIN
public/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2
vendored
Normal file
Binary file not shown.
BIN
public/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2
vendored
Normal file
BIN
public/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2
vendored
Normal file
Binary file not shown.
@@ -2,142 +2,59 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title data-i18n-key="title">FileRise</title>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<meta name="csrf-token" content="">
|
||||
<meta name="share-url" content="">
|
||||
<style>
|
||||
/* hide the app shell until JS says otherwise */
|
||||
.main-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* full-screen white overlay while we check auth */
|
||||
#loadingOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-color, #fff);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||
<style id="pretheme-css">
|
||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||
</style>
|
||||
<!-- Google Fonts and Material Icons -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
|
||||
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
|
||||
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"
|
||||
integrity="sha384-xPpkMo5nDgD98fIcuRVYhxkZV6/9Y4L8s3p0J5c4MxgJkyKJ8BJr+xfRkq7kn6Tw"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"
|
||||
integrity="sha384-to8njsu2GAiXQnY/aLGzz0DIY/SFSeSDodtvSl869n2NmsBdHOTZNNqbEBPYh7Pa"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"
|
||||
integrity="sha384-kmQrbJf09Uo1WRLMDVGoVG3nM6F48frIhcj7f3FDUjeRzsiHwyBWDjMUIttnIeAf"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"
|
||||
integrity="sha384-EXTg7rRfdTPZWoKVCslusAAev2TYw76fm+Wox718iEtFQ+gdAdAc5Z/ndLHSo4mq"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
|
||||
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
||||
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
||||
crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
||||
|
||||
|
||||
<!-- Critical CSS -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Fonts (ok to keep as real preloads) -->
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Vendor & version (deferred) -->
|
||||
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
<!-- App entry -->
|
||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="appRoot" style="visibility:hidden">
|
||||
<header class="header-container">
|
||||
|
||||
<div class="header-left">
|
||||
<a href="index.html">
|
||||
<div class="header-logo">
|
||||
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve">
|
||||
<defs>
|
||||
<!-- Gradient for the cabinet body -->
|
||||
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Drop shadow filter with animated attributes for a lifting effect -->
|
||||
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow id="dropShadow" dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2">
|
||||
<!-- Animate the vertical offset: from 2 to 1 (as it rises), hold, then back to 2 -->
|
||||
<animate attributeName="dy" values="2;1;1;2" keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
<!-- Animate the blur similarly: from 2 to 1.5 then back to 2 -->
|
||||
<animate attributeName="stdDeviation" values="2;1.5;1.5;2" keyTimes="0;0.2;0.8;1" dur="5s"
|
||||
fill="freeze" />
|
||||
</feDropShadow>
|
||||
</filter>
|
||||
</defs>
|
||||
<style type="text/css">
|
||||
/* Cabinet with gradient, white outline, and drop shadow */
|
||||
.cabinet {
|
||||
fill: url(#cabinetGradient);
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
stroke: #1565C0;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.handle {
|
||||
fill: #1565C0;
|
||||
}
|
||||
</style>
|
||||
<!-- Group that will animate upward and then back down once -->
|
||||
<g id="cabinetGroup">
|
||||
<!-- Cabinet Body with rounded corners, white outline, and drop shadow -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="6" ry="6" class="cabinet" filter="url(#shadowFilter)" />
|
||||
<!-- Divider lines for drawers -->
|
||||
<line x1="5" y1="22" x2="59" y2="22" class="divider" />
|
||||
<line x1="5" y1="34" x2="59" y2="34" class="divider" />
|
||||
<!-- Drawers with Handles -->
|
||||
<rect x="8" y="24" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="27" r="1.5" class="handle" />
|
||||
<rect x="8" y="36" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="39" r="1.5" class="handle" />
|
||||
<rect x="8" y="48" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="51" r="1.5" class="handle" />
|
||||
<!-- Additional detail: a small top handle on the cabinet door -->
|
||||
<rect x="28" y="10" width="8" height="4" rx="1" ry="1" fill="#1565C0" />
|
||||
<!-- Animate transform: rises by 2 pixels over 1s, holds for 3s, then falls over 1s (total 5s) -->
|
||||
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -2; 0 -2; 0 0"
|
||||
keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
</g>
|
||||
</svg>
|
||||
<img
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise"
|
||||
class="logo"
|
||||
width="50"
|
||||
height="50"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="header-title">
|
||||
<h1 data-i18n-key="header_title">FileRise</h1>
|
||||
<h1>FileRise</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||
<!-- Your header drop zone -->
|
||||
|
||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||
<div class="header-buttons">
|
||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||
@@ -156,7 +73,7 @@
|
||||
<!-- Trash items will be loaded here -->
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
|
||||
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
|
||||
Selected</button>
|
||||
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
||||
@@ -172,7 +89,7 @@
|
||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
|
||||
<span class="material-icons" id="darkModeIcon">
|
||||
dark_mode
|
||||
</span>
|
||||
@@ -181,15 +98,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<div id="loadingOverlay"></div>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
|
||||
<main id="main" hidden>
|
||||
<div class="row mt-4" id="loginForm">
|
||||
<div class="col-12">
|
||||
<div id="loginBox" class="login-box">
|
||||
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<form id="authForm" method="post">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername" data-i18n-key="user">User:</label>
|
||||
@@ -199,7 +119,7 @@
|
||||
<label for="loginPassword" data-i18n-key="password">Password:</label>
|
||||
<input type="password" class="form-control" id="loginPassword" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login" data-default>Login</button>
|
||||
<div class="form-group remember-me-container">
|
||||
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
||||
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
|
||||
@@ -215,11 +135,14 @@
|
||||
HTTP
|
||||
Login</a>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||
<div class="main-wrapper">
|
||||
<div class="main-wrapper" hidden>
|
||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
||||
<!-- Main Column -->
|
||||
@@ -286,9 +209,27 @@
|
||||
</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" data-default>Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
|
||||
<div id="renameFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||
@@ -299,7 +240,7 @@
|
||||
<button id="cancelRenameFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFolder" class="btn btn-primary"
|
||||
data-i18n-key="rename">Rename</button>
|
||||
data-i18n-key="rename" data-default>Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +260,7 @@
|
||||
<button id="cancelDeleteFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFolder" class="btn btn-danger"
|
||||
data-i18n-key="delete">Delete</button>
|
||||
data-i18n-key="delete" data-default>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +296,7 @@
|
||||
selected files?</p>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete" data-default>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,7 +310,7 @@
|
||||
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy" data-default>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,41 +324,25 @@
|
||||
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||
data-i18n-key="download_zip">Download ZIP</button>
|
||||
<button id="extractZipBtn" class="btn action-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>
|
||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
||||
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||
<button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n-key="create">Create</span>
|
||||
<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')}
|
||||
<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:10010; min-width:160px;">
|
||||
<li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_file">Create file</span>
|
||||
</li>
|
||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
||||
${t('create_folder')}
|
||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_folder">Create folder</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -425,16 +350,11 @@
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create" data-default>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +366,7 @@
|
||||
placeholder="files.zip" />
|
||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,14 +404,14 @@
|
||||
placeholder="Filename" />
|
||||
<div style="margin-top: 15px; text-align: right;">
|
||||
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||
@@ -500,7 +420,7 @@
|
||||
placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
|
||||
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;" data-default>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addUserModal" class="modal" style="display:none;">
|
||||
@@ -525,7 +445,7 @@
|
||||
Cancel
|
||||
</button>
|
||||
<!-- Save becomes type="submit" -->
|
||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
|
||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user" data-default>
|
||||
Save User
|
||||
</button>
|
||||
</div>
|
||||
@@ -550,7 +470,7 @@
|
||||
placeholder="Enter new file name" style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename" data-default>Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,12 +478,12 @@
|
||||
<div class="modal-content">
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes">Yes</button>
|
||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes" data-default>Yes</button>
|
||||
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
177
public/js/appCore.js
Normal file
177
public/js/appCore.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// /js/appCore.js
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||
const _nativeFetch = window.fetch.bind(window);
|
||||
|
||||
/* =========================
|
||||
CSRF UTILITIES (shared)
|
||||
========================= */
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function getCsrfToken() {
|
||||
return window.csrfToken || localStorage.getItem('csrf') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap/refresh CSRF from the server.
|
||||
* Uses the native fetch to avoid wrapper loops and accepts rotated tokens via header.
|
||||
*/
|
||||
export async function loadCsrfToken() {
|
||||
const res = await _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
|
||||
|
||||
// header-based rotation
|
||||
const hdr = res.headers.get('X-CSRF-Token');
|
||||
if (hdr) setCsrfToken(hdr);
|
||||
|
||||
// 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"]');
|
||||
if (!shareMeta) {
|
||||
shareMeta = document.createElement('meta');
|
||||
shareMeta.name = 'share-url';
|
||||
document.head.appendChild(shareMeta);
|
||||
}
|
||||
shareMeta.content = actualShare;
|
||||
|
||||
return { csrf_token: token, share_url: actualShare };
|
||||
}
|
||||
|
||||
/* =========================
|
||||
APP INIT (shared)
|
||||
========================= */
|
||||
export function initializeApp() {
|
||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||
|
||||
const last = localStorage.getItem('lastOpenedFolder');
|
||||
window.currentFolder = last ? last : "root";
|
||||
|
||||
const stored = localStorage.getItem('showFoldersInList');
|
||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||
|
||||
// Load public site config early (safe subset)
|
||||
loadAdminConfigFunc();
|
||||
|
||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||
initTagSearch();
|
||||
|
||||
// Hook DnD relay from fileList area into upload area
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
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
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// App subsystems
|
||||
initDragAndDrop();
|
||||
loadSidebarOrder();
|
||||
loadHeaderOrder();
|
||||
initFileActions();
|
||||
initUpload();
|
||||
loadFolderTree();
|
||||
|
||||
// Only run trash/restore for admins
|
||||
const isAdmin =
|
||||
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||
if (isAdmin) {
|
||||
setupTrashRestoreDelete();
|
||||
}
|
||||
|
||||
// Small help tooltip toggle
|
||||
const helpBtn = document.getElementById("folderHelpBtn");
|
||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||
if (helpBtn && helpTooltip) {
|
||||
helpBtn.addEventListener("click", () => {
|
||||
helpTooltip.style.display =
|
||||
helpTooltip.style.display === "block" ? "none" : "block";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
LOGOUT (shared)
|
||||
========================= */
|
||||
export function triggerLogout() {
|
||||
const clearWelcomeFlags = () => {
|
||||
try {
|
||||
// one-per-tab toast guard
|
||||
sessionStorage.removeItem('__fr_welcomed');
|
||||
// if you also used the per-user (all-tabs) guard, clear that too:
|
||||
const u = localStorage.getItem('username') || '';
|
||||
if (u) localStorage.removeItem(`__fr_welcomed_${u}`);
|
||||
} catch { }
|
||||
};
|
||||
|
||||
_nativeFetch("/api/auth/logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||
})
|
||||
.then(() => {
|
||||
clearWelcomeFlags();
|
||||
window.location.reload(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// even if the request fails, clear the flags so the next login can toast
|
||||
clearWelcomeFlags();
|
||||
window.location.reload(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Global UX guard (unchanged)
|
||||
========================= */
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { t, applyTranslations } from './i18n.js';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { t, applyTranslations } from './i18n.js?v={{APP_QVER}}';
|
||||
import {
|
||||
toggleVisibility,
|
||||
showToast as originalShowToast,
|
||||
attachEnterKeyListener,
|
||||
showCustomConfirmModal
|
||||
} from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import { renderFileTable } from './fileListView.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
} from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { renderFileTable } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import {
|
||||
openTOTPLoginModal as originalOpenTOTPLoginModal,
|
||||
openUserPanel,
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
closeTOTPModal,
|
||||
setLastLoginData,
|
||||
openApiModal
|
||||
} from './authModals.js';
|
||||
import { openAdminPanel } from './adminPanel.js';
|
||||
import { initializeApp, triggerLogout } from './main.js';
|
||||
} from './authModals.js?v={{APP_QVER}}';
|
||||
import { openAdminPanel } from './adminPanel.js?v={{APP_QVER}}';
|
||||
import { initializeApp, triggerLogout } from './appCore.js?v={{APP_QVER}}';
|
||||
|
||||
// Production OIDC configuration (override via API as needed)
|
||||
const currentOIDCConfig = {
|
||||
@@ -31,18 +31,81 @@ const currentOIDCConfig = {
|
||||
};
|
||||
window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
|
||||
|
||||
(function installToastFilter() {
|
||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
||||
|
||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||
// Suppress the nag while doing TOTP step-up
|
||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return null; // suppress
|
||||
}
|
||||
|
||||
// Demo host
|
||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||
}
|
||||
|
||||
// Try to translate keys; pass through plain text
|
||||
try {
|
||||
const maybe = t(msgKeyOrText);
|
||||
if (typeof maybe === 'string' && maybe !== msgKeyOrText) return maybe;
|
||||
} catch { }
|
||||
return msgKeyOrText;
|
||||
};
|
||||
})();
|
||||
|
||||
function queueWelcomeToast(name) {
|
||||
const uname = String(name || '').trim().slice(0, 80);
|
||||
if (!uname) return;
|
||||
// show immediately (if we don’t reload instantly)
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('filerise:toast', {
|
||||
detail: { message: `Welcome back, ${uname}!`, duration: 2000 }
|
||||
}));
|
||||
} catch { }
|
||||
|
||||
// and persist for after-reload (flushed by main.js on boot)
|
||||
try {
|
||||
sessionStorage.setItem('welcomeMessage', `Welcome back, ${uname}!`);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/* ----------------- TOTP & Toast Overrides ----------------- */
|
||||
// detect if we’re in a pending‑TOTP state
|
||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||
|
||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||
function showToast(msgKey) {
|
||||
const msg = t(msgKey);
|
||||
if (window.pendingTOTP && msgKey === "please_log_in_to_continue") {
|
||||
|
||||
function showToast(msgKeyOrText, type) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
@@ -52,45 +115,51 @@ const originalFetch = window.fetch;
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
|
||||
export async function fetchWithCsrf(url, options = {}) {
|
||||
// 1) Merge in credentials + header
|
||||
options = {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
|
||||
|
||||
options = { credentials: 'include', ...options };
|
||||
options.headers = {
|
||||
...(options.headers || {}),
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Accept': 'application/json',
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
// 2) First attempt
|
||||
let res = await originalFetch(url, options);
|
||||
|
||||
// 3) If we got a 403, try to refresh token & retry
|
||||
if (res.status === 403) {
|
||||
// 3a) See if the server gave us a new token header
|
||||
let newToken = res.headers.get('X-CSRF-Token');
|
||||
// 3b) Otherwise fall back to the /api/auth/token endpoint
|
||||
if (!newToken) {
|
||||
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
||||
if (tokRes.ok) {
|
||||
const body = await tokRes.json();
|
||||
newToken = body.csrf_token;
|
||||
}
|
||||
}
|
||||
if (newToken) {
|
||||
// 3c) Update global + meta
|
||||
window.csrfToken = newToken;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = newToken;
|
||||
|
||||
// 3d) Retry the original request with the new token
|
||||
options.headers['X-CSRF-Token'] = newToken;
|
||||
res = await originalFetch(url, options);
|
||||
}
|
||||
if (window.csrfToken) {
|
||||
options.headers['X-CSRF-Token'] = window.csrfToken;
|
||||
}
|
||||
|
||||
// 4) Return the real Response—no body peeking here!
|
||||
async function retryWithFreshCsrf(asFormFallback = false) {
|
||||
const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
|
||||
if (tokRes.ok) {
|
||||
const body = await tokRes.json().catch(() => ({}));
|
||||
if (body?.csrf_token) {
|
||||
window.csrfToken = body.csrf_token;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = body.csrf_token;
|
||||
options.headers['X-CSRF-Token'] = body.csrf_token;
|
||||
}
|
||||
}
|
||||
if (asFormFallback && wantJson) {
|
||||
// convert JSON body into x-www-form-urlencoded
|
||||
const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
|
||||
options.body = toFormBody(orig);
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
return original(url, options);
|
||||
}
|
||||
|
||||
let res = await original(url, options);
|
||||
|
||||
// If API doesn’t like JSON or token is stale
|
||||
if (res.status === 400 || res.status === 403 || res.status === 415) {
|
||||
// 1) retry with fresh CSRF keeping same encoding
|
||||
res = await retryWithFreshCsrf(false);
|
||||
if (!res.ok && wantJson) {
|
||||
// 2) retry again as form-encoded
|
||||
res = await retryWithFreshCsrf(true);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -160,28 +229,32 @@ function updateLoginOptionsUIFromStorage() {
|
||||
}
|
||||
|
||||
export function loadAdminConfigFunc() {
|
||||
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
localStorage.setItem("headerTitle", config.header_title || "FileRise");
|
||||
return fetch("/api/siteConfig.php", { credentials: "include" })
|
||||
.then(async (response) => {
|
||||
// If a proxy or some edge returns 204/empty, handle gracefully
|
||||
let config = {};
|
||||
try { config = await response.json(); } catch { config = {}; }
|
||||
|
||||
// Update login options using the nested loginOptions object.
|
||||
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
||||
const headerTitle = config.header_title || "FileRise";
|
||||
localStorage.setItem("headerTitle", headerTitle);
|
||||
|
||||
document.title = headerTitle;
|
||||
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");
|
||||
localStorage.setItem("authBypass", String(!!config.loginOptions.authBypass));
|
||||
localStorage.setItem("authHeaderName", config.loginOptions.authHeaderName || "X-Remote-User");
|
||||
// These may be absent for non-admins; default them
|
||||
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||
|
||||
updateLoginOptionsUIFromStorage();
|
||||
|
||||
const headerTitleElem = document.querySelector(".header-title h1");
|
||||
if (headerTitleElem) {
|
||||
headerTitleElem.textContent = config.header_title || "FileRise";
|
||||
}
|
||||
if (headerTitleElem) headerTitleElem.textContent = headerTitle;
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults.
|
||||
// Fallback defaults if request truly fails
|
||||
localStorage.setItem("headerTitle", "FileRise");
|
||||
localStorage.setItem("disableFormLogin", "false");
|
||||
localStorage.setItem("disableBasicAuth", "false");
|
||||
@@ -190,9 +263,7 @@ export function loadAdminConfigFunc() {
|
||||
updateLoginOptionsUIFromStorage();
|
||||
|
||||
const headerTitleElem = document.querySelector(".header-title h1");
|
||||
if (headerTitleElem) {
|
||||
headerTitleElem.textContent = "FileRise";
|
||||
}
|
||||
if (headerTitleElem) headerTitleElem.textContent = "FileRise";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (loading) loading.remove();
|
||||
|
||||
// 2) Show main UI
|
||||
document.querySelector('.main-wrapper').style.display = '';
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.querySelector('.main-wrapper').style.display = '';
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
toggleVisibility("loginForm", false);
|
||||
toggleVisibility("mainOperations", true);
|
||||
toggleVisibility("uploadFileForm", true);
|
||||
toggleVisibility("fileListContainer", true);
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||
|
||||
// 3) Persist auth flags (unchanged)
|
||||
@@ -249,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
|
||||
localStorage.setItem("username", data.username);
|
||||
}
|
||||
if (typeof data.folderOnly !== "undefined") {
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
||||
}
|
||||
|
||||
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||
@@ -260,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
|
||||
|
||||
// 5) Build / update 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) {
|
||||
@@ -268,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (!r) {
|
||||
r = document.createElement("button");
|
||||
r.id = "restoreFilesBtn";
|
||||
r.classList.add("btn","btn-warning");
|
||||
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||
r.classList.add("btn", "btn-warning");
|
||||
r.setAttribute("data-i18n-title", "trash_restore_delete");
|
||||
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||
if (firstButton) insertAfter(r, firstButton);
|
||||
else headerButtons.appendChild(r);
|
||||
@@ -286,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (!a) {
|
||||
a = document.createElement("button");
|
||||
a.id = "adminPanelBtn";
|
||||
a.classList.add("btn","btn-info");
|
||||
a.setAttribute("data-i18n-title","admin_panel");
|
||||
a.classList.add("btn", "btn-info");
|
||||
a.setAttribute("data-i18n-title", "admin_panel");
|
||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||
a.addEventListener("click", openAdminPanel);
|
||||
@@ -308,19 +379,19 @@ export async function updateAuthenticatedUI(data) {
|
||||
: `<i class="material-icons">account_circle</i>`;
|
||||
|
||||
// fallback username if missing
|
||||
const usernameText = data.username
|
||||
|| localStorage.getItem("username")
|
||||
const usernameText = data.username
|
||||
|| localStorage.getItem("username")
|
||||
|| "";
|
||||
|
||||
if (!dd) {
|
||||
dd = document.createElement("div");
|
||||
dd.id = "userDropdown";
|
||||
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.id = "userDropdownToggle";
|
||||
toggle.classList.add("btn", "btn-user");
|
||||
toggle.setAttribute("title", t("user_settings"));
|
||||
toggle.innerHTML = `
|
||||
${avatarHTML}
|
||||
@@ -442,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
|
||||
}
|
||||
updateAuthenticatedUI(data);
|
||||
return data;
|
||||
|
||||
// at the end of updateAuthenticatedUI(data)
|
||||
if (!window.__FR_FLAGS?.initialized && typeof initializeApp === 'function') {
|
||||
initializeApp();
|
||||
window.__FR_FLAGS.initialized = true;
|
||||
}
|
||||
if (typeof applyTranslations === 'function') applyTranslations();
|
||||
if (typeof updateLoginOptionsUIFromStorage === 'function') updateLoginOptionsUIFromStorage();
|
||||
} else {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.remove();
|
||||
@@ -462,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
|
||||
}
|
||||
|
||||
/* ----------------- Authentication Submission ----------------- */
|
||||
async function primeCsrfStrict() {
|
||||
const r = await fetch('/api/auth/token.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
|
||||
window.csrfToken = j.csrf_token;
|
||||
const m = document.querySelector('meta[name="csrf-token"]');
|
||||
if (m) m.content = j.csrf_token;
|
||||
}
|
||||
|
||||
function toFormBody(obj) {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v));
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
async function safeJson(res) {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (!/application\/json/i.test(ct)) return null;
|
||||
try { return await res.clone().json(); } catch { return null; }
|
||||
}
|
||||
|
||||
async function sniffTOTP(res, bodyMaybe) {
|
||||
if (res.headers.get('X-TOTP-Required') === '1') return true;
|
||||
if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true;
|
||||
const body = bodyMaybe ?? await safeJson(res);
|
||||
if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true;
|
||||
try {
|
||||
const txt = await res.clone().text();
|
||||
if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true;
|
||||
} catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isAuthedNow() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
return !!j.authenticated;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function rafTick(times = 2) {
|
||||
return new Promise(res => {
|
||||
const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); };
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAuthSnapshot() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
|
||||
return await r.json();
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function syncPermissionsToLocalStorage() {
|
||||
try {
|
||||
const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' });
|
||||
const perm = await r.json();
|
||||
if (perm && typeof perm === 'object') {
|
||||
localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false');
|
||||
localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false');
|
||||
localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false');
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ——— main ———
|
||||
let __loginInFlight = false;
|
||||
|
||||
async function submitLogin(data) {
|
||||
setLastLoginData(data);
|
||||
window.__lastLoginData = data;
|
||||
if (__loginInFlight) return;
|
||||
__loginInFlight = true;
|
||||
|
||||
const payload = {
|
||||
username: String(data.username || '').trim(),
|
||||
password: String(data.password || '').trim(),
|
||||
remember_me: data.remember_me ? 1 : 0
|
||||
};
|
||||
|
||||
setLastLoginData(payload);
|
||||
window.__lastLoginData = payload;
|
||||
|
||||
try {
|
||||
// ─── 1) Get CSRF for the initial auth call ───
|
||||
let res = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Could not fetch CSRF token");
|
||||
window.csrfToken = (await res.json()).csrf_token;
|
||||
await primeCsrfStrict();
|
||||
|
||||
// ─── 2) Send credentials ───
|
||||
const response = await sendRequest(
|
||||
"/api/auth/auth.php",
|
||||
"POST",
|
||||
data,
|
||||
{ "X-CSRF-Token": window.csrfToken }
|
||||
);
|
||||
// Attempt #1 — JSON
|
||||
let res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let body = await safeJson(res);
|
||||
|
||||
// ─── 3a) Full login (no TOTP) ───
|
||||
if (response.success || response.status === "ok") {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
|
||||
// … fetch permissions & reload …
|
||||
// TOTP requested?
|
||||
if (await sniffTOTP(res, body)) {
|
||||
try { await primeCsrfStrict(); } catch { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
|
||||
if (perm && typeof perm === "object") {
|
||||
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
|
||||
}
|
||||
const auth = await import('/js/auth.js?v={{APP_QVER}}');
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch { }
|
||||
return window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── 3b) TOTP required ───
|
||||
if (response.totp_required) {
|
||||
// **Refresh** CSRF before the TOTP verify call
|
||||
res = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
window.csrfToken = (await res.json()).csrf_token;
|
||||
}
|
||||
// now open the modal—any totp_verify fetch from here on will use the new token
|
||||
return openTOTPLoginModal();
|
||||
// Full success (no TOTP)
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// ─── 3c) Too many attempts ───
|
||||
if (response.error && response.error.includes("Too many failed login attempts")) {
|
||||
showToast(response.error);
|
||||
// Cookie set but non-JSON body — double check session
|
||||
if (!body && await isAuthedNow()) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Attempt #2 — form fallback
|
||||
res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
||||
body: toFormBody(payload)
|
||||
});
|
||||
body = await safeJson(res);
|
||||
|
||||
if (await sniffTOTP(res, body)) {
|
||||
try { await primeCsrfStrict(); } catch { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const auth = await import('/js/auth.js?v={{APP_QVER}}');
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (!body && await isAuthedNow()) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Rate limit still respected
|
||||
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
|
||||
showToast(body.error);
|
||||
const btn = document.querySelector("#authForm button[type='submit']");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
@@ -520,12 +708,12 @@ async function submitLogin(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── 3d) Other failures ───
|
||||
showToast("Login failed: " + (response.error || "Unknown error"));
|
||||
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
|
||||
|
||||
} catch (err) {
|
||||
const msg = err.message || err.error || "Unknown error";
|
||||
showToast(`Login failed: ${msg}`);
|
||||
} catch (e) {
|
||||
showToast('Login failed: ' + (e.message || 'Unknown error'));
|
||||
} finally {
|
||||
__loginInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
});
|
||||
|
||||
export { initAuth, checkAuthentication };
|
||||
export { initAuth, checkAuthentication, openTOTPLoginModal };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
let lastLoginData = null;
|
||||
export function setLastLoginData(data) {
|
||||
@@ -328,10 +328,19 @@ export async function openUserPanel() {
|
||||
const langSel = document.createElement('select');
|
||||
langSel.id = 'languageSelector';
|
||||
langSel.className = 'form-select';
|
||||
['en', 'es', 'fr', 'de'].forEach(code => {
|
||||
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;
|
||||
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
|
||||
// use i18n if available, otherwise fallback
|
||||
opt.textContent = (typeof t === 'function' ? t(labelKey) : '') || fallback;
|
||||
langSel.appendChild(opt);
|
||||
});
|
||||
langSel.value = localStorage.getItem('language') || 'en';
|
||||
|
||||
31
public/js/defer-css.js
Normal file
31
public/js/defer-css.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// /public/js/defer-css.js
|
||||
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
|
||||
(function () {
|
||||
if (window.__CSS_PROMISE__) return;
|
||||
|
||||
var loads = [];
|
||||
|
||||
// Promote <link rel="preload" as="style"> IN-PLACE
|
||||
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
|
||||
for (var i = 0; i < preloads.length; i++) {
|
||||
var l = preloads[i];
|
||||
// resolve when it finishes loading as a stylesheet
|
||||
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
|
||||
l.rel = 'stylesheet';
|
||||
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
|
||||
l.removeAttribute('as'); // keep some engines happy about "used" preload
|
||||
}
|
||||
|
||||
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
|
||||
var styles = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (var j = 0; j < styles.length; j++) {
|
||||
var s = styles[j];
|
||||
if (s.sheet) continue; // already applied
|
||||
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
|
||||
}
|
||||
|
||||
// Safari quirk: nudge layout so promoted sheets apply immediately
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
window.__CSS_PROMISE__ = Promise.all(loads);
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
// domUtils.js
|
||||
import { t } from './i18n.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
|
||||
// Basic DOM Helpers
|
||||
export function toggleVisibility(elementId, shouldShow) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
// fileActions.js
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { formatFolderName } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
e.preventDefault();
|
||||
@@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
||||
if (confirmDelete) {
|
||||
confirmDelete.setAttribute("data-default", "");
|
||||
confirmDelete.addEventListener("click", function () {
|
||||
fetch("/api/file/deleteFiles.php", {
|
||||
method: "POST",
|
||||
@@ -316,6 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// 2) Confirm button kicks off the zip+download
|
||||
if (confirmZipBtn) {
|
||||
confirmZipBtn.setAttribute("data-default", "");
|
||||
confirmZipBtn.addEventListener("click", async () => {
|
||||
// a) Validate ZIP filename
|
||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||
@@ -478,6 +480,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
const confirmCopy = document.getElementById("confirmCopyFiles");
|
||||
if (confirmCopy) {
|
||||
confirmCopy.setAttribute("data-default", "");
|
||||
confirmCopy.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("copyTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
@@ -529,6 +532,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
const confirmMove = document.getElementById("confirmMoveFiles");
|
||||
if (confirmMove) {
|
||||
confirmMove.setAttribute("data-default", "");
|
||||
confirmMove.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("moveTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
@@ -598,6 +602,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const submitBtn = document.getElementById("submitRenameFile");
|
||||
if (submitBtn) {
|
||||
submitBtn.setAttribute("data-default", "");
|
||||
submitBtn.addEventListener("click", function () {
|
||||
const newName = document.getElementById("newFileName").value.trim();
|
||||
if (!newName || newName === window.fileToRename) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// fileDragDrop.js
|
||||
import { showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = event.currentTarget;
|
||||
|
||||
@@ -1,24 +1,311 @@
|
||||
// fileEditor.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
|
||||
// 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
|
||||
|
||||
// ==== CodeMirror lazy loader ===============================================
|
||||
const CM_BASE = "/vendor/codemirror/5.65.5/";
|
||||
|
||||
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
|
||||
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
||||
|
||||
const CORE = {
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
css: coreUrl("codemirror.min.css"),
|
||||
themeCss: coreUrl("theme/material-darker.min.css"),
|
||||
};
|
||||
|
||||
// Which mode file to load for a given name/mime
|
||||
const MODE_URL = {
|
||||
// core/common
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||
|
||||
// meta / combos
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||
|
||||
// docs / data
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
|
||||
// shells
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
|
||||
// languages
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
};
|
||||
|
||||
// Mode dependency graph
|
||||
const MODE_DEPS = {
|
||||
"htmlmixed": ["xml", "javascript", "css"],
|
||||
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
|
||||
"markdown": ["xml"]
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE integration -----------------------------------------------
|
||||
|
||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||
|
||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
|
||||
|
||||
async function fetchOnlyOfficeCapsOnce() {
|
||||
if (__ooCaps.fetched) return __ooCaps;
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
__ooCaps.enabled = !!j.enabled;
|
||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
}
|
||||
} catch { /* ignore; keep defaults */ }
|
||||
__ooCaps.fetched = true;
|
||||
return __ooCaps;
|
||||
}
|
||||
|
||||
async function shouldUseOnlyOffice(fileName) {
|
||||
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
|
||||
return enabled && exts.has(getExt(fileName));
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||
|
||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||
let src =
|
||||
srcFromConfig ||
|
||||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
|
||||
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
|
||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||
await loadScriptOnce(src);
|
||||
}
|
||||
|
||||
async function openOnlyOffice(fileName, folder) {
|
||||
let editor; // make visible to the whole function
|
||||
|
||||
try {
|
||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
|
||||
const text = await resp.text();
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(text); } catch {
|
||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||
}
|
||||
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||
|
||||
// Must be absolute
|
||||
const docUrl = cfg?.document?.url;
|
||||
const cbUrl = cfg?.editorConfig?.callbackUrl;
|
||||
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
|
||||
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
|
||||
}
|
||||
|
||||
// Load DocsAPI if needed
|
||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||
|
||||
// Modal
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'ooEditorModal';
|
||||
modal.classList.add('modal', 'editor-modal');
|
||||
modal.setAttribute('tabindex', '-1');
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">
|
||||
${t("editing")}: ${escapeHTML(fileName)}
|
||||
</h3>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||
</div>
|
||||
<div class="editor-body" style="flex:1;min-height:200px">
|
||||
<div id="oo-editor" style="width:100%;height:100%"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
modal.focus();
|
||||
|
||||
// We’ll fill this after wiring the toggle, so destroy() can unhook it
|
||||
let removeThemeListener = () => {};
|
||||
|
||||
const destroy = () => {
|
||||
try { editor?.destroyEditor?.(); } catch {}
|
||||
try { removeThemeListener(); } catch {}
|
||||
try { modal.remove(); } catch {}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
|
||||
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
|
||||
|
||||
// Let DS request closing
|
||||
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
|
||||
|
||||
// Initial theme
|
||||
const isDark =
|
||||
document.documentElement.classList.contains('dark-mode') ||
|
||||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
|
||||
cfg.editorConfig = cfg.editorConfig || {};
|
||||
cfg.editorConfig.customization = Object.assign(
|
||||
{},
|
||||
cfg.editorConfig.customization,
|
||||
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
|
||||
);
|
||||
|
||||
// Launch editor
|
||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||
|
||||
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
const onDarkToggle = () => {
|
||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||
if (editor && typeof editor.setTheme === 'function') {
|
||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', onDarkToggle);
|
||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ONLYOFFICE] failed to open:', e);
|
||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||
}
|
||||
}
|
||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||
|
||||
|
||||
const _loadedScripts = new Set();
|
||||
const _loadedCss = new Set();
|
||||
let _corePromise = null;
|
||||
|
||||
function loadScriptOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedScripts.has(url)) return resolve();
|
||||
const s = document.createElement("script");
|
||||
s.src = url;
|
||||
s.async = true;
|
||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCssOnce(href) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedCss.has(href)) return resolve();
|
||||
const l = document.createElement("link");
|
||||
l.rel = "stylesheet";
|
||||
l.href = href;
|
||||
l.onload = () => { _loadedCss.add(href); resolve(); };
|
||||
l.onerror = () => reject(new Error(`Load failed: ${href}`));
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCore() {
|
||||
if (_corePromise) return _corePromise;
|
||||
_corePromise = (async () => {
|
||||
// load CSS first to avoid FOUC
|
||||
await loadCssOnce(CORE.css);
|
||||
await loadCssOnce(CORE.themeCss);
|
||||
if (!window.CodeMirror) {
|
||||
await loadScriptOnce(CORE.js);
|
||||
}
|
||||
})();
|
||||
return _corePromise;
|
||||
}
|
||||
|
||||
async function loadSingleMode(name) {
|
||||
const rel = MODE_URL[name];
|
||||
if (!rel) return;
|
||||
// prepend base if needed
|
||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||
await loadScriptOnce(url);
|
||||
}
|
||||
|
||||
function isModeRegistered(name) {
|
||||
return !!(
|
||||
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
|
||||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureModeLoaded(modeOption) {
|
||||
await ensureCore();
|
||||
const name = normalizeModeName(modeOption);
|
||||
if (!name) return;
|
||||
if (isModeRegistered(name)) return;
|
||||
const deps = MODE_DEPS[name] || [];
|
||||
for (const d of deps) {
|
||||
if (!isModeRegistered(d)) await loadSingleMode(d);
|
||||
}
|
||||
await loadSingleMode(name);
|
||||
}
|
||||
|
||||
// Public helper for callers (we keep your existing function name in use):
|
||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||
// ==== /CodeMirror lazy loader ===============================================
|
||||
|
||||
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) {
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return { name: "javascript", json: true };
|
||||
case "js":
|
||||
return "javascript";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "text/html";
|
||||
case "xml":
|
||||
return "xml";
|
||||
default:
|
||||
return "text/plain";
|
||||
case "htm": return "text/html";
|
||||
case "xml": return "xml";
|
||||
case "md":
|
||||
case "markdown": return "markdown";
|
||||
case "yml":
|
||||
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 };
|
||||
@@ -35,119 +322,204 @@ export { adjustEditorSize };
|
||||
|
||||
function observeModalResize(modal) {
|
||||
if (!modal) return;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
adjustEditorSize();
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
|
||||
resizeObserver.observe(modal);
|
||||
}
|
||||
export { observeModalResize };
|
||||
|
||||
export function editFile(fileName, folder) {
|
||||
export async function editFile(fileName, folder) {
|
||||
// destroy any previous editor
|
||||
let existingEditor = document.getElementById("editorContainer");
|
||||
if (existingEditor) {
|
||||
existingEditor.remove();
|
||||
}
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
||||
if (existingEditor) existingEditor.remove();
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||
|
||||
if (await shouldUseOnlyOffice(fileName)) {
|
||||
await openOnlyOffice(fileName, folderUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||
async function probeSize(url) {
|
||||
try {
|
||||
const h = await fetch(url, { method: "HEAD", credentials: "include" });
|
||||
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
|
||||
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
|
||||
} catch { }
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-0" },
|
||||
credentials: "include"
|
||||
});
|
||||
// Content-Range: bytes 0-0/12345
|
||||
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
|
||||
const m = cr && cr.match(/\/(\d+)\s*$/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
probeSize(fileUrl)
|
||||
.then(sizeBytes => {
|
||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return fetch(fileUrl);
|
||||
return fetch(fileUrl, { credentials: "include" });
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error! Status: " + response.status);
|
||||
}
|
||||
return response.text();
|
||||
if (!response.ok) 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 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");
|
||||
modal.id = "editorContainer";
|
||||
modal.classList.add("modal", "editor-modal");
|
||||
modal.setAttribute("tabindex", "-1"); // for Escape handling
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</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 class="editor-header">
|
||||
<h3 class="editor-title">
|
||||
${t("editing")}: ${escapeHTML(fileName)}
|
||||
${forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""}
|
||||
</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>
|
||||
<button id="closeEditorX" class="editor-close-btn">×</button>
|
||||
</div>
|
||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||
<div class="editor-footer">
|
||||
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
|
||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||
</div>
|
||||
`;
|
||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||
<div class="editor-footer">
|
||||
<button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
|
||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
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 theme = isDarkMode ? "material-darker" : "default";
|
||||
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||
|
||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
||||
lineNumbers: true,
|
||||
mode: mode,
|
||||
theme: theme,
|
||||
viewportMargin: Infinity
|
||||
});
|
||||
// Start core+mode loading (don’t block closing)
|
||||
const modePromise = (async () => {
|
||||
await ensureCore(); // load CM core + CSS
|
||||
if (!forcePlainText) {
|
||||
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
|
||||
}
|
||||
})();
|
||||
|
||||
window.currentEditor = editor;
|
||||
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
||||
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
||||
|
||||
setTimeout(() => {
|
||||
adjustEditorSize();
|
||||
}, 50);
|
||||
Promise.race([modePromise, timeout]).then(() => {
|
||||
if (canceled) return;
|
||||
|
||||
observeModalResize(modal);
|
||||
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;
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||
|
||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
const cmOptions = {
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: initialMode,
|
||||
theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false
|
||||
};
|
||||
|
||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
const editor = window.CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
cmOptions
|
||||
);
|
||||
window.currentEditor = editor;
|
||||
|
||||
setTimeout(adjustEditorSize, 50);
|
||||
observeModalResize(modal);
|
||||
|
||||
// Font controls (now that editor exists)
|
||||
let currentFontSize = 14;
|
||||
const wrapper = editor.getWrapperElement();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
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) {
|
||||
const nn = normalizeModeName(desiredMode);
|
||||
if (nn && isModeRegistered(nn)) {
|
||||
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) {
|
||||
const editor = window.currentEditor;
|
||||
if (!editor) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user