Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
44
.gitattributes
vendored
@@ -1,4 +1,40 @@
|
|||||||
public/api.html linguist-documentation
|
# --- Docs that shouldn't count toward code stats
|
||||||
public/openapi.json linguist-documentation
|
public/api.php linguist-documentation
|
||||||
resources/ export-ignore
|
public/openapi.json linguist-documentation
|
||||||
.github/ export-ignore
|
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
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
github: [error311]
|
||||||
|
ko_fi: error311
|
||||||
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
|
||||||
70
.github/workflows/sync-changelog.yml
vendored
@@ -1,44 +1,92 @@
|
|||||||
---
|
---
|
||||||
name: Sync Changelog to Docker Repo
|
name: Bump version and sync Changelog to Docker Repo
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'CHANGELOG.md'
|
- "CHANGELOG.md"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
bump_and_sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout FileRise
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
path: file-rise
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# ✂️ REMOVED: repo stamping of HTML/CSS/JS
|
||||||
|
|
||||||
|
- 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
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checkout filerise-docker
|
- name: Checkout filerise-docker
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: error311/filerise-docker
|
repository: error311/filerise-docker
|
||||||
token: ${{ secrets.PAT_TOKEN }}
|
token: ${{ secrets.PAT_TOKEN }}
|
||||||
path: docker-repo
|
path: docker-repo
|
||||||
|
|
||||||
- name: Copy CHANGELOG.md
|
- name: Copy CHANGELOG.md and write VERSION
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
|
shell: bash
|
||||||
run: |
|
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
|
working-directory: docker-repo
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md VERSION
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||||
git push origin main
|
git push origin main
|
||||||
fi
|
fi
|
||||||
|
|||||||
308
CHANGELOG.md
@@ -1,5 +1,313 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## Changes 10/23/2025 (v1.6.2)
|
||||||
|
|
||||||
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
||||||
|
|||||||
67
README.md
@@ -7,6 +7,8 @@
|
|||||||
[](https://demo.filerise.net)
|
[](https://demo.filerise.net)
|
||||||
[](https://github.com/error311/FileRise/releases)
|
[](https://github.com/error311/FileRise/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://github.com/sponsors/error311)
|
||||||
|
[](https://ko-fi.com/error311)
|
||||||
|
|
||||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||||
|
|
||||||
@@ -21,9 +23,9 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2
|
|||||||
|
|
||||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
**4/3/2025 Video demo:**
|
**10/25/2025 Video demo:**
|
||||||
|
|
||||||
<https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e>
|
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||||
|
|
||||||
**Dark mode:**
|
**Dark mode:**
|
||||||

|

|
||||||
@@ -103,6 +105,22 @@ Deploy FileRise using the **Docker image** (quickest) or a **manual install** on
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `TIMEZONE` | `UTC` | PHP/app timezone. |
|
||||||
|
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
|
||||||
|
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
|
||||||
|
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
|
||||||
|
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
|
||||||
|
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
|
||||||
|
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
|
||||||
|
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
|
||||||
|
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 1) Running with Docker (Recommended)
|
### 1) Running with Docker (Recommended)
|
||||||
|
|
||||||
#### Pull the image
|
#### Pull the image
|
||||||
@@ -121,7 +139,7 @@ docker run -d \
|
|||||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||||
-e SECURE="false" \
|
-e SECURE="false" \
|
||||||
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||||
-e PUID="1000" \
|
-e PUID="1000" \
|
||||||
-e PGID="1000" \
|
-e PGID="1000" \
|
||||||
-e CHOWN_ON_START="true" \
|
-e CHOWN_ON_START="true" \
|
||||||
@@ -133,6 +151,8 @@ docker run -d \
|
|||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
|
||||||
|
|
||||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
@@ -155,10 +175,10 @@ docker exec -it filerise id www-data
|
|||||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
filerise:
|
filerise:
|
||||||
image: error311/filerise-docker:latest
|
image: error311/filerise-docker:latest
|
||||||
|
container_name: filerise
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
@@ -166,7 +186,7 @@ services:
|
|||||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
TOTAL_UPLOAD_SIZE: "10G"
|
||||||
SECURE: "false"
|
SECURE: "false"
|
||||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||||
# Ownership & indexing
|
# Ownership & indexing
|
||||||
PUID: "1000" # Unraid users often use 99
|
PUID: "1000" # Unraid users often use 99
|
||||||
PGID: "1000" # Unraid users often use 100
|
PGID: "1000" # Unraid users often use 100
|
||||||
@@ -178,11 +198,14 @@ services:
|
|||||||
- ./uploads:/var/www/uploads
|
- ./uploads:/var/www/uploads
|
||||||
- ./users:/var/www/users
|
- ./users:/var/www/users
|
||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
Access at `http://localhost:8080` (or your server’s IP).
|
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.
|
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||||
|
|
||||||
|
- “`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
|
||||||
|
|
||||||
**First-time Setup**
|
**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.
|
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||||
|
|
||||||
@@ -247,6 +270,13 @@ Browse to your FileRise URL; you’ll be prompted to create the Admin user on fi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 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
|
## Unraid
|
||||||
|
|
||||||
- Install from **Community Apps** → search **FileRise**.
|
- Install from **Community Apps** → search **FileRise**.
|
||||||
@@ -256,6 +286,16 @@ Browse to your FileRise URL; you’ll be prompted to create the Admin user on fi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Quick-start: Mount via WebDAV
|
||||||
|
|
||||||
Once FileRise is running, enable WebDAV in the admin panel.
|
Once FileRise is running, enable WebDAV in the admin panel.
|
||||||
@@ -336,6 +376,17 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 💖 Sponsor FileRise
|
||||||
|
|
||||||
|
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
|
||||||
|
|
||||||
|
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
|
||||||
|
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
|
||||||
|
|
||||||
|
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Community and Support
|
## Community and Support
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
- **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).
|
||||||
@@ -373,6 +424,10 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License & Credits
|
||||||
|
|
||||||
MIT License – see [LICENSE](LICENSE).
|
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.
|
||||||
|
|||||||
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
@@ -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
|
||||||
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
@@ -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
@@ -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.
|
||||||
111
public/.htaccess
@@ -1,75 +1,88 @@
|
|||||||
# -----------------------------
|
# --------------------------------
|
||||||
# 1) Prevent directory listings
|
# Base: safe in most environments
|
||||||
# -----------------------------
|
# --------------------------------
|
||||||
Options -Indexes
|
Options -Indexes
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Default index files
|
|
||||||
# -----------------------------
|
|
||||||
DirectoryIndex index.html
|
DirectoryIndex index.html
|
||||||
|
|
||||||
# -----------------------------
|
<IfModule mod_authz_core.c>
|
||||||
# Deny access to hidden files
|
<FilesMatch "^\.">
|
||||||
# -----------------------------
|
Require all denied
|
||||||
<FilesMatch "^\.">
|
</FilesMatch>
|
||||||
Require all denied
|
</IfModule>
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Enforce HTTPS (optional)
|
|
||||||
# -----------------------------
|
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
# If you want forced HTTPS behind a proxy, keep this off here and do it at the proxy
|
||||||
#RewriteCond %{HTTPS} off
|
#RewriteCond %{HTTPS} off
|
||||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
<IfModule mod_headers.c>
|
# MIME types (fonts/SVG/ESM)
|
||||||
# Allow requests from a specific origin
|
<IfModule mod_mime.c>
|
||||||
#Header set Access-Control-Allow-Origin "https://demo.filerise.net"
|
AddType font/woff2 .woff2
|
||||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
AddType font/woff .woff
|
||||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
|
AddType image/svg+xml .svg
|
||||||
Header set Access-Control-Allow-Credentials "true"
|
AddType application/javascript .mjs
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
# Security headers
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
# Prevent clickjacking
|
|
||||||
Header always set X-Frame-Options "SAMEORIGIN"
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
# Block XSS
|
|
||||||
Header always set X-XSS-Protection "1; mode=block"
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
# No MIME sniffing
|
|
||||||
Header always set X-Content-Type-Options "nosniff"
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
# HSTS: only if HTTPS (prevents mixed local dev warnings)
|
||||||
|
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
|
||||||
|
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"
|
||||||
|
# Nice extra hardening (same-origin resource sharing)
|
||||||
|
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
Header always set X-Permitted-Cross-Domain-Policies "none"
|
||||||
|
|
||||||
|
# CSP (modules, workers, blobs already accounted for)
|
||||||
|
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
# Caching
|
||||||
|
SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
# HTML: always revalidate
|
# HTML/PHP: no cache (app shell)
|
||||||
<FilesMatch "\.(html|htm)$">
|
<FilesMatch "\.(html?|php)$">
|
||||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
Header set Pragma "no-cache"
|
Header set Pragma "no-cache"
|
||||||
Header set Expires "0"
|
Header set Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
# JS/CSS: short‑term cache, revalidate regularly
|
|
||||||
<FilesMatch "\.(js|css)$">
|
# version.js is your source-of-truth; keep it non-cacheable so dev/CI flips show up
|
||||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
<FilesMatch "^js/version\.js$">
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Unversioned JS/CSS (dev): 1 hour
|
||||||
|
<FilesMatch "\.(?:m?js|css)$">
|
||||||
|
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!has_version_param
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Unversioned static assets (dev): 7 days
|
||||||
|
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||||
|
Header set Cache-Control "public, max-age=604800" env=!has_version_param
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Versioned assets (?v=...): 1 year + immutable
|
||||||
|
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||||
|
Header set Cache-Control "public, max-age=31536000, immutable" env=has_version_param
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Compression (if modules exist)
|
||||||
|
<IfModule mod_brotli.c>
|
||||||
|
BrotliCompressionQuality 5
|
||||||
|
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||||
|
</IfModule>
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# -----------------------------
|
# Disable TRACE
|
||||||
# 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"
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Disable TRACE method
|
|
||||||
# -----------------------------
|
|
||||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||||
RewriteRule .* - [F]
|
RewriteRule .* - [F]
|
||||||
@@ -19,13 +19,11 @@ if (isset($_GET['spec'])) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>FileRise API Docs</title>
|
<title>FileRise API Docs</title>
|
||||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
<script defer src="/vendor/redoc/redoc.standalone.js?v={{APP_QVER}}"></script>
|
||||||
integrity="sha384-70P5pmIdaQdVbxvjhrcTDv1uKcKqalZ3OHi7S2J+uzDl0PW8dO6L+pHOpm9EEjGJ"
|
<script defer src="/js/redoc-init.js?v={{APP_QVER}}"></script>
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script defer src="/js/redoc-init.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<redoc spec-url="api.php?spec=1"></redoc>
|
<redoc spec-url="/api.php?spec=1"></redoc>
|
||||||
<div id="redoc-container"></div>
|
<div id="redoc-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -153,7 +153,6 @@ if ($folder !== 'root') {
|
|||||||
$perms = loadPermsFor($username);
|
$perms = loadPermsFor($username);
|
||||||
$isAdmin = ACL::isAdmin($perms);
|
$isAdmin = ACL::isAdmin($perms);
|
||||||
$readOnly = !empty($perms['readOnly']);
|
$readOnly = !empty($perms['readOnly']);
|
||||||
$disableUp = !empty($perms['disableUpload']);
|
|
||||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||||
|
|
||||||
// --- ACL base abilities ---
|
// --- ACL base abilities ---
|
||||||
@@ -178,7 +177,7 @@ $gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
|||||||
|
|
||||||
// --- Apply scope + flags to effective UI actions ---
|
// --- Apply scope + flags to effective UI actions ---
|
||||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||||
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
|
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
||||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||||
@@ -186,6 +185,7 @@ $canDelete = $gDeleteBase && !$readOnly && $inScope;
|
|||||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||||
$canMoveIn = $canReceive;
|
$canMoveIn = $canReceive;
|
||||||
|
$canMoveAlias = $canMoveIn;
|
||||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||||
@@ -201,6 +201,12 @@ if ($isRoot) {
|
|||||||
$canRename = false;
|
$canRename = false;
|
||||||
$canDelete = false;
|
$canDelete = false;
|
||||||
$canShareFoldEff = false;
|
$canShareFoldEff = false;
|
||||||
|
$canMoveFolder = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isRoot) {
|
||||||
|
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||||
|
&& !$readOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
$owner = null;
|
$owner = null;
|
||||||
@@ -213,7 +219,6 @@ echo json_encode([
|
|||||||
'flags' => [
|
'flags' => [
|
||||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||||
'readOnly' => $readOnly,
|
'readOnly' => $readOnly,
|
||||||
'disableUpload' => $disableUp,
|
|
||||||
],
|
],
|
||||||
'owner' => $owner,
|
'owner' => $owner,
|
||||||
|
|
||||||
@@ -227,6 +232,8 @@ echo json_encode([
|
|||||||
'canRename' => $canRename,
|
'canRename' => $canRename,
|
||||||
'canDelete' => $canDelete,
|
'canDelete' => $canDelete,
|
||||||
'canMoveIn' => $canMoveIn,
|
'canMoveIn' => $canMoveIn,
|
||||||
|
'canMove' => $canMoveAlias,
|
||||||
|
'canMoveFolder'=> $canMoveFolder,
|
||||||
'canEdit' => $canEdit,
|
'canEdit' => $canEdit,
|
||||||
'canCopy' => $canCopy,
|
'canCopy' => $canCopy,
|
||||||
'canExtract' => $canExtract,
|
'canExtract' => $canExtract,
|
||||||
|
|||||||
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();
|
||||||
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();
|
||||||
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) 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
@@ -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') 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') 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') 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') 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/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2
vendored
Normal file
BIN
public/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2
vendored
Normal file
@@ -9,58 +9,34 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
<meta name="csrf-token" content="">
|
<meta name="csrf-token" content="">
|
||||||
<meta name="share-url" content="">
|
<meta name="share-url" content="">
|
||||||
<style>
|
|
||||||
/* hide the app shell until JS says otherwise */
|
|
||||||
.main-wrapper {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* full-screen white overlay while we check auth */
|
<style>.main-wrapper{display:none}#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}</style>
|
||||||
#loadingOverlay {
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
position: fixed;
|
<link rel="stylesheet" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
|
||||||
top: 0;
|
|
||||||
left: 0;
|
<!-- Bootstrap CSS (local) -->
|
||||||
right: 0;
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
bottom: 0;
|
|
||||||
background: var(--bg-color, #fff);
|
<!-- CodeMirror CSS (local) -->
|
||||||
z-index: 9999;
|
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/codemirror.min.css?v={{APP_QVER}}">
|
||||||
display: flex;
|
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/theme/material-darker.min.css?v={{APP_QVER}}">
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
<!-- app CSS -->
|
||||||
}
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
</style>
|
|
||||||
<!-- Google Fonts and Material Icons -->
|
<!-- Libraries (JS) -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}"></script>
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<script src="/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}"></script>
|
||||||
<!-- Bootstrap CSS -->
|
<script src="/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}"></script>
|
||||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
|
||||||
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
<!-- CodeMirror core FIRST -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
|
<script src="/vendor/codemirror/5.65.5/codemirror.min.js?v={{APP_QVER}}"></script>
|
||||||
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"
|
<script src="/js/version.js?v={{APP_QVER}}"></script>
|
||||||
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
|
||||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||||
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -68,67 +44,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a href="index.html">
|
<a href="index.html">
|
||||||
<div class="header-logo">
|
<div class="header-logo">
|
||||||
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
|
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise" class="logo" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,9 +202,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
|
||||||
|
<i class="material-icons">drive_file_move</i>
|
||||||
|
</button>
|
||||||
|
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
|
||||||
|
<div id="moveFolderModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
|
||||||
|
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
|
||||||
|
into:</p>
|
||||||
|
<select id="moveFolderTarget" class="form-control modal-input"></select>
|
||||||
|
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||||
|
<button id="cancelMoveFolder" class="btn btn-secondary"
|
||||||
|
data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="renameFolderModal" class="modal">
|
<div id="renameFolderModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||||
@@ -389,16 +323,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||||
data-i18n-key="download_zip">Download ZIP</button>
|
data-i18n-key="download_zip">Download ZIP</button>
|
||||||
<button id="extractZipBtn" class="btn 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>
|
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||||
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create">
|
||||||
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
${t('create')} <span class="material-icons"
|
||||||
</button>
|
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
<ul
|
</button>
|
||||||
id="createMenu"
|
<ul id="createMenu" class="dropdown-menu" style="
|
||||||
class="dropdown-menu"
|
|
||||||
style="
|
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
@@ -411,27 +343,23 @@
|
|||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
"
|
">
|
||||||
>
|
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
|
||||||
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
${t('create_file')}
|
${t('create_file')}
|
||||||
</li>
|
</li>
|
||||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
|
||||||
${t('create_folder')}
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
</li>
|
${t('create_folder')}
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
<div id="createFileModal" class="modal" style="display:none;">
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||||
<input
|
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
|
||||||
type="text"
|
data-i18n-placeholder="newfile_placeholder" />
|
||||||
id="createFileNameInput"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Enter filename…"
|
|
||||||
data-i18n-placeholder="newfile_placeholder"
|
|
||||||
/>
|
|
||||||
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
<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="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">Create</button>
|
||||||
@@ -563,7 +491,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="js/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
// adminPanel.js
|
// adminPanel.js
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
const version = "v1.6.2";
|
const version = window.APP_VERSION || "dev";
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||||
|
|
||||||
|
|
||||||
|
function buildFullGrantsForAllFolders(folders) {
|
||||||
|
const allTrue = {
|
||||||
|
view:true, viewOwn:false, manage:true, create:true, upload:true, edit:true,
|
||||||
|
rename:true, copy:true, move:true, delete:true, extract:true,
|
||||||
|
shareFile:true, shareFolder:true, share:true
|
||||||
|
};
|
||||||
|
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
|
||||||
|
}
|
||||||
|
|
||||||
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
||||||
function qs(scope, sel){ return (scope||document).querySelector(sel); }
|
function qs(scope, sel){ return (scope||document).querySelector(sel); }
|
||||||
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
|
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
|
||||||
@@ -50,7 +59,7 @@ function onShareFileToggle(row, checked) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onWriteToggle(row, checked) {
|
function onWriteToggle(row, checked) {
|
||||||
const caps = ["create","upload","edit","rename","copy","move","delete","extract"];
|
const caps = ["create","upload","edit","rename","copy","delete","extract"];
|
||||||
caps.forEach(c => {
|
caps.forEach(c => {
|
||||||
const box = qs(row, `input[data-cap="${c}"]`);
|
const box = qs(row, `input[data-cap="${c}"]`);
|
||||||
if (box) box.checked = checked;
|
if (box) box.checked = checked;
|
||||||
@@ -194,6 +203,25 @@ async function safeJson(res) {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder cell: horizontal-only scroll */
|
||||||
|
.folder-cell{
|
||||||
|
overflow-x:auto;
|
||||||
|
overflow-y:hidden;
|
||||||
|
white-space:nowrap;
|
||||||
|
-webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
/* nicer thin scrollbar (supported browsers) */
|
||||||
|
.folder-cell::-webkit-scrollbar{ height:8px; }
|
||||||
|
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
|
||||||
|
body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||||
|
|
||||||
|
/* Badge now doesn't clip; let the wrapper handle scroll */
|
||||||
|
.folder-badge{
|
||||||
|
display:inline-flex; align-items:center; gap:6px;
|
||||||
|
font-weight:600;
|
||||||
|
min-width:0; /* allow child to be as wide as needed inside scroller */
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
})();
|
})();
|
||||||
@@ -617,21 +645,29 @@ export async function closeAdminPanel() {
|
|||||||
New: Folder Access (ACL) UI
|
New: Folder Access (ACL) UI
|
||||||
=========================== */
|
=========================== */
|
||||||
|
|
||||||
let __allFoldersCache = null; // array of folder strings
|
let __allFoldersCache = null;
|
||||||
async function getAllFolders() {
|
|
||||||
if (__allFoldersCache) return __allFoldersCache.slice();
|
async function getAllFolders(force = false) {
|
||||||
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
|
if (!force && __allFoldersCache) return __allFoldersCache.slice();
|
||||||
const data = await safeJson(res).catch(() => []);
|
|
||||||
const list = Array.isArray(data)
|
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
|
||||||
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
credentials: 'include',
|
||||||
: [];
|
cache: 'no-store',
|
||||||
const hidden = new Set(["profile_pics", "trash"]);
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
const cleaned = list
|
});
|
||||||
.filter(f => f && !hidden.has(f.toLowerCase()))
|
const data = await safeJson(res).catch(() => []);
|
||||||
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
const list = Array.isArray(data)
|
||||||
__allFoldersCache = cleaned;
|
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
||||||
return cleaned.slice();
|
: [];
|
||||||
}
|
|
||||||
|
const hidden = new Set(['profile_pics', 'trash']);
|
||||||
|
const cleaned = list
|
||||||
|
.filter(f => f && !hidden.has(f.toLowerCase()))
|
||||||
|
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
||||||
|
|
||||||
|
__allFoldersCache = cleaned;
|
||||||
|
return cleaned.slice();
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserGrants(username) {
|
async function getUserGrants(username) {
|
||||||
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
||||||
@@ -647,25 +683,32 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
// toolbar
|
// toolbar
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'folder-access-toolbar';
|
toolbar.className = 'folder-access-toolbar';
|
||||||
toolbar.innerHTML = `
|
toolbar.innerHTML = `
|
||||||
<input type="text" class="form-control" style="max-width:220px;" placeholder="${tf('search_folders', 'Search folders')}" />
|
<input type="text" class="form-control" style="max-width:220px;"
|
||||||
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
placeholder="${tf('search_folders', 'Search folders')}" />
|
||||||
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
|
|
||||||
</label>
|
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
||||||
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
|
||||||
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own)')}
|
</label>
|
||||||
</label>
|
|
||||||
<label class="muted" title="${tf('write_help', 'Create/upload files and edit/rename/copy/delete items in this folder')}">
|
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
||||||
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (upload/edit/delete)')}
|
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own files)')}
|
||||||
</label>
|
</label>
|
||||||
<label class="muted" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">
|
|
||||||
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage')}
|
<label class="muted" title="${tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract ZIPs')}">
|
||||||
</label>
|
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (file ops)')}
|
||||||
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
|
</label>
|
||||||
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
|
|
||||||
</label>
|
<label class="muted" title="${tf('manage_help', 'Folder-level (owner): can create/rename/move folders and grant access; implies View (all)')}">
|
||||||
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
|
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage (folder owner)')}
|
||||||
`;
|
</label>
|
||||||
|
|
||||||
|
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
|
||||||
|
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
|
||||||
|
`;
|
||||||
container.appendChild(toolbar);
|
container.appendChild(toolbar);
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@@ -674,31 +717,64 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
|
|
||||||
const headerHtml = `
|
const headerHtml = `
|
||||||
<div class="folder-access-header">
|
<div class="folder-access-header">
|
||||||
<div title="${tf('folder_help', 'Folder path within FileRise')}">${tf('folder', 'Folder')}</div>
|
<div class="folder-cell" title="${tf('folder_help','Folder path within FileRise')}">
|
||||||
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">${tf('view_all', 'View (all)')}</div>
|
${tf('folder','Folder')}
|
||||||
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">${tf('view_own', 'View (own)')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all write operations (below) on/off for this row')}">${tf('write_full', 'Write')}</div>
|
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
||||||
<div class="perm-col" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">${tf('manage', 'Manage')}</div>
|
${tf('view_all', 'View (all)')}
|
||||||
<div class="perm-col" title="${tf('create_help', 'Create empty files')}">${tf('create', 'Create')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('upload_help', 'Upload files to this folder')}">${tf('upload', 'Upload')}</div>
|
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
||||||
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">${tf('edit', 'Edit')}</div>
|
${tf('view_own', 'View (own)')}
|
||||||
<div class="perm-col" title="${tf('rename_help', 'Rename files')}">${tf('rename', 'Rename')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('copy_help', 'Copy files')}">${tf('copy', 'Copy')}</div>
|
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all file-level operations below')}">
|
||||||
<div class="perm-col" title="${tf('move_help', 'Move files: requires Manage')}">${tf('move', 'Move')}</div>
|
${tf('write_full', 'Write')}
|
||||||
<div class="perm-col" title="${tf('delete_help', 'Delete files/folders')}">${tf('delete', 'Delete')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">${tf('extract', 'Extract ZIP')}</div>
|
<div class="perm-col" title="${tf('manage_help', 'Folder owner: can create/rename/move folders and grant access; implies View (all)')}">
|
||||||
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">${tf('share_file', 'Share File')}</div>
|
${tf('manage', 'Manage')}
|
||||||
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires View all)')}">${tf('share_folder', 'Share Folder')}</div>
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('create_help', 'Create empty file')}">
|
||||||
|
${tf('create', 'Create File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('upload_help', 'Upload a file into this folder')}">
|
||||||
|
${tf('upload', 'Upload File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">
|
||||||
|
${tf('edit', 'Edit File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('rename_help', 'Rename a file')}">
|
||||||
|
${tf('rename', 'Rename File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('copy_help', 'Copy a file')}">
|
||||||
|
${tf('copy', 'Copy File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('delete_help', 'Delete a file')}">
|
||||||
|
${tf('delete', 'Delete File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">
|
||||||
|
${tf('extract', 'Extract ZIP')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">
|
||||||
|
${tf('share_file', 'Share File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires Manage + View (all))')}">
|
||||||
|
${tf('share_folder', 'Share Folder')}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
function rowHtml(folder) {
|
function rowHtml(folder) {
|
||||||
const g = grants[folder] || {};
|
const g = grants[folder] || {};
|
||||||
const name = folder === 'root' ? '(Root)' : folder;
|
const name = folder === 'root' ? '(Root)' : folder;
|
||||||
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.move || g.delete || g.extract);
|
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.delete || g.extract);
|
||||||
const shareFolderDisabled = !g.view;
|
const shareFolderDisabled = !g.view;
|
||||||
return `
|
return `
|
||||||
<div class="folder-access-row" data-folder="${folder}">
|
<div class="folder-access-row" data-folder="${folder}">
|
||||||
<div class="folder-badge"><i class="material-icons" style="font-size:18px;">folder</i>${name}<span class="inherited-tag" style="display:none;"></span></div>
|
<div class="folder-cell">
|
||||||
|
<div class="folder-badge">
|
||||||
|
<i class="material-icons" style="font-size:18px;">folder</i>
|
||||||
|
${name}
|
||||||
|
<span class="inherited-tag" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="write" ${writeMetaChecked ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="write" ${writeMetaChecked ? 'checked' : ''}></div>
|
||||||
@@ -708,7 +784,6 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="move" ${g.move ? 'checked' : ''}></div>
|
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
|
||||||
@@ -744,7 +819,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
if (v) v.checked = true;
|
if (v) v.checked = true;
|
||||||
if (w) w.checked = true;
|
if (w) w.checked = true;
|
||||||
if (vo) { vo.checked = false; vo.disabled = true; }
|
if (vo) { vo.checked = false; vo.disabled = true; }
|
||||||
['create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder']
|
['create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder']
|
||||||
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
|
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
|
||||||
setRowDisabled(row, true);
|
setRowDisabled(row, true);
|
||||||
const tag = row.querySelector('.inherited-tag');
|
const tag = row.querySelector('.inherited-tag');
|
||||||
@@ -844,7 +919,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
const w = r.querySelector('input[data-cap="write"]');
|
const w = r.querySelector('input[data-cap="write"]');
|
||||||
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
||||||
const boxes = [
|
const boxes = [
|
||||||
'create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder'
|
'create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder'
|
||||||
].map(c => r.querySelector(`input[data-cap="${c}"]`));
|
].map(c => r.querySelector(`input[data-cap="${c}"]`));
|
||||||
if (m) m.checked = checked;
|
if (m) m.checked = checked;
|
||||||
if (v) v.checked = checked;
|
if (v) v.checked = checked;
|
||||||
@@ -999,15 +1074,16 @@ export function openUserPermissionsModal() {
|
|||||||
});
|
});
|
||||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
||||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||||
const changes = [];
|
const changes = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const username = String(row.getAttribute("data-username") || "").trim();
|
if (row.getAttribute("data-admin") === "1") return; // skip admins
|
||||||
if (!username) return;
|
const username = String(row.getAttribute("data-username") || "").trim();
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
if (!username) return;
|
||||||
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
const grants = collectGrantsFrom(grantsBox);
|
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
||||||
changes.push({ user: username, grants });
|
const grants = collectGrantsFrom(grantsBox);
|
||||||
});
|
changes.push({ user: username, grants });
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
||||||
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
||||||
@@ -1053,14 +1129,17 @@ async function fetchAllUserFlags() {
|
|||||||
function flagRow(u, flags) {
|
function flagRow(u, flags) {
|
||||||
const f = flags[u.username] || {};
|
const f = flags[u.username] || {};
|
||||||
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
|
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
|
||||||
if (isAdmin) return "";
|
|
||||||
|
const disabledAttr = isAdmin ? "disabled data-admin='1' title='Admin: full access'" : "";
|
||||||
|
const note = isAdmin ? " <span class='muted'>(Admin)</span>" : "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr data-username="${u.username}">
|
<tr data-username="${u.username}" ${isAdmin ? "data-admin='1'" : ""}>
|
||||||
<td><strong>${u.username}</strong></td>
|
<td><strong>${u.username}</strong>${note}</td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked" : ""} ${disabledAttr}></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1092,7 +1171,7 @@ export async function openUserFlagsModal() {
|
|||||||
|
|
||||||
<h3>${tf("user_permissions", "User Permissions")}</h3>
|
<h3>${tf("user_permissions", "User Permissions")}</h3>
|
||||||
<p class="muted" style="margin-top:-6px;">
|
<p class="muted" style="margin-top:-6px;">
|
||||||
${tf("user_flags_help", "Account-level switches. These are NOT per-folder grants.")}
|
${tf("user_flags_help", "Non Admin User Account-level switches. These are NOT per-folder grants.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="userFlagsBody"
|
<div id="userFlagsBody"
|
||||||
@@ -1158,6 +1237,7 @@ async function saveUserFlags() {
|
|||||||
const rows = body?.querySelectorAll("tbody tr[data-username]") || [];
|
const rows = body?.querySelectorAll("tbody tr[data-username]") || [];
|
||||||
const permissions = [];
|
const permissions = [];
|
||||||
rows.forEach(tr => {
|
rows.forEach(tr => {
|
||||||
|
if (tr.getAttribute("data-admin") === "1") return; // don't send admin updates
|
||||||
const username = tr.getAttribute("data-username");
|
const username = tr.getAttribute("data-username");
|
||||||
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
|
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
|
||||||
permissions.push({
|
permissions.push({
|
||||||
@@ -1201,61 +1281,73 @@ async function loadUserPermissionsList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = await getAllFolders();
|
const folders = await getAllFolders(true);
|
||||||
|
|
||||||
listContainer.innerHTML = "";
|
listContainer.innerHTML = "";
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return;
|
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.classList.add("user-permission-row");
|
row.classList.add("user-permission-row");
|
||||||
row.setAttribute("data-username", user.username);
|
row.setAttribute("data-username", user.username);
|
||||||
row.style.padding = "6px 0";
|
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
|
||||||
|
row.style.padding = "6px 0";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
||||||
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
||||||
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
||||||
<strong>${user.username}</strong>
|
<strong>${user.username}</strong>
|
||||||
<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>
|
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
|
||||||
</div>
|
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
|
||||||
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
</div>
|
||||||
<div class="folder-grants-box" data-loaded="0"></div>
|
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
||||||
</div>
|
<div class="folder-grants-box" data-loaded="0"></div>
|
||||||
`;
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const header = row.querySelector(".user-perm-header");
|
const header = row.querySelector(".user-perm-header");
|
||||||
const details = row.querySelector(".user-perm-details");
|
const details = row.querySelector(".user-perm-details");
|
||||||
const caret = row.querySelector(".perm-caret");
|
const caret = row.querySelector(".perm-caret");
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
|
|
||||||
async function ensureLoaded() {
|
async function ensureLoaded() {
|
||||||
if (grantsBox.dataset.loaded === "1") return;
|
if (grantsBox.dataset.loaded === "1") return;
|
||||||
try {
|
try {
|
||||||
const grants = await getUserGrants(user.username);
|
let grants;
|
||||||
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], grants);
|
if (isAdmin) {
|
||||||
grantsBox.dataset.loaded = "1";
|
// synthesize full access
|
||||||
} catch (e) {
|
const ordered = ["root", ...folders.filter(f => f !== "root")];
|
||||||
console.error(e);
|
grants = buildFullGrantsForAllFolders(ordered);
|
||||||
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
|
||||||
}
|
// disable all inputs
|
||||||
|
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
|
||||||
|
} else {
|
||||||
|
const userGrants = await getUserGrants(user.username);
|
||||||
|
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
|
||||||
}
|
}
|
||||||
|
grantsBox.dataset.loaded = "1";
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
const willShow = details.style.display === "none";
|
const willShow = details.style.display === "none";
|
||||||
details.style.display = willShow ? "block" : "none";
|
details.style.display = willShow ? "block" : "none";
|
||||||
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
||||||
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||||
if (willShow) ensureLoaded();
|
if (willShow) ensureLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
header.addEventListener("click", toggleOpen);
|
header.addEventListener("click", toggleOpen);
|
||||||
header.addEventListener("keydown", e => {
|
header.addEventListener("keydown", e => {
|
||||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||||
|
|||||||
160
public/js/appCore.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// /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() {
|
||||||
|
_nativeFetch("/api/auth/logout.php", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||||
|
})
|
||||||
|
.then(() => window.location.reload(true))
|
||||||
|
.catch(() => { /* no-op */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
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 { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { t, applyTranslations } from './i18n.js';
|
import { t, applyTranslations } from './i18n.js?v={{APP_QVER}}';
|
||||||
import {
|
import {
|
||||||
toggleVisibility,
|
toggleVisibility,
|
||||||
showToast as originalShowToast,
|
showToast as originalShowToast,
|
||||||
attachEnterKeyListener,
|
attachEnterKeyListener,
|
||||||
showCustomConfirmModal
|
showCustomConfirmModal
|
||||||
} from './domUtils.js';
|
} from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { initFileActions } from './fileActions.js';
|
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { renderFileTable } from './fileListView.js';
|
import { renderFileTable } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import {
|
import {
|
||||||
openTOTPLoginModal as originalOpenTOTPLoginModal,
|
openTOTPLoginModal as originalOpenTOTPLoginModal,
|
||||||
openUserPanel,
|
openUserPanel,
|
||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
closeTOTPModal,
|
closeTOTPModal,
|
||||||
setLastLoginData,
|
setLastLoginData,
|
||||||
openApiModal
|
openApiModal
|
||||||
} from './authModals.js';
|
} from './authModals.js?v={{APP_QVER}}';
|
||||||
import { openAdminPanel } from './adminPanel.js';
|
import { openAdminPanel } from './adminPanel.js?v={{APP_QVER}}';
|
||||||
import { initializeApp, triggerLogout } from './main.js';
|
import { initializeApp, triggerLogout } from './appCore.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// Production OIDC configuration (override via API as needed)
|
// Production OIDC configuration (override via API as needed)
|
||||||
const currentOIDCConfig = {
|
const currentOIDCConfig = {
|
||||||
@@ -180,7 +180,7 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadAdminConfigFunc() {
|
export function loadAdminConfigFunc() {
|
||||||
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
return fetch("/api/siteConfig.php", { credentials: "include" })
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
// If a proxy or some edge returns 204/empty, handle gracefully
|
// If a proxy or some edge returns 204/empty, handle gracefully
|
||||||
let config = {};
|
let config = {};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
export function setLastLoginData(data) {
|
export function setLastLoginData(data) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// domUtils.js
|
// domUtils.js
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { openDownloadModal } from './fileActions.js';
|
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// Basic DOM Helpers
|
// Basic DOM Helpers
|
||||||
export function toggleVisibility(elementId, shouldShow) {
|
export function toggleVisibility(elementId, shouldShow) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// fileActions.js
|
// fileActions.js
|
||||||
import { showToast, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { formatFolderName } from './fileListView.js';
|
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function handleDeleteSelected(e) {
|
export function handleDeleteSelected(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// fileDragDrop.js
|
// fileDragDrop.js
|
||||||
import { showToast } from './domUtils.js';
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function fileDragStartHandler(event) {
|
export function fileDragStartHandler(event) {
|
||||||
const row = event.currentTarget;
|
const row = event.currentTarget;
|
||||||
|
|||||||
@@ -1,42 +1,43 @@
|
|||||||
// fileEditor.js
|
// fileEditor.js
|
||||||
import { escapeHTML, showToast } from './domUtils.js';
|
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// thresholds for editor behavior
|
// thresholds for editor behavior
|
||||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
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
|
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||||
|
|
||||||
// Lazy-load CodeMirror modes on demand
|
// Lazy-load CodeMirror modes on demand
|
||||||
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||||
|
const CM_LOCAL = "/vendor/codemirror/5.65.5/";
|
||||||
|
|
||||||
// Which mode file to load for a given name/mime
|
// Which mode file to load for a given name/mime
|
||||||
const MODE_URL = {
|
const MODE_URL = {
|
||||||
// core/common
|
// core/common
|
||||||
"xml": "mode/xml/xml.min.js",
|
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||||
"css": "mode/css/css.min.js",
|
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||||
"javascript": "mode/javascript/javascript.min.js",
|
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// meta / combos
|
// meta / combos
|
||||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||||
"application/x-httpd-php": "mode/php/php.min.js",
|
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// docs / data
|
// docs / data
|
||||||
"markdown": "mode/markdown/markdown.min.js",
|
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||||
"yaml": "mode/yaml/yaml.min.js",
|
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||||
"properties": "mode/properties/properties.min.js",
|
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||||
"sql": "mode/sql/sql.min.js",
|
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// shells
|
// shells
|
||||||
"shell": "mode/shell/shell.min.js",
|
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// languages
|
// languages
|
||||||
"python": "mode/python/python.min.js",
|
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||||
"text/x-csrc": "mode/clike/clike.min.js",
|
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-c++src": "mode/clike/clike.min.js",
|
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-java": "mode/clike/clike.min.js",
|
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-csharp": "mode/clike/clike.min.js",
|
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-kotlin": "mode/clike/clike.min.js"
|
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map any mime/alias to the key we use in MODE_URL
|
// Map any mime/alias to the key we use in MODE_URL
|
||||||
@@ -48,50 +49,27 @@ function normalizeModeName(modeOption) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_SRI = {
|
|
||||||
"mode/xml/xml.min.js": "sha512-LarNmzVokUmcA7aUDtqZ6oTS+YXmUKzpGdm8DxC46A6AHu+PQiYCUlwEGWidjVYMo/QXZMFMIadZtrkfApYp/g==",
|
|
||||||
"mode/css/css.min.js": "sha512-oikhYLgIKf0zWtVTOXh101BWoSacgv4UTJHQOHU+iUQ1Dol3Xjz/o9Jh0U33MPoT/d4aQruvjNvcYxvkTQd0nA==",
|
|
||||||
"mode/javascript/javascript.min.js": "sha512-I6CdJdruzGtvDyvdO4YsiAq+pkWf2efgd1ZUSK2FnM/u2VuRASPC7GowWQrWyjxCZn6CT89s3ddGI+be0Ak9Fg==",
|
|
||||||
"mode/htmlmixed/htmlmixed.min.js": "sha512-HN6cn6mIWeFJFwRN9yetDAMSh+AK9myHF1X9GlSlKmThaat65342Yw8wL7ITuaJnPioG0SYG09gy0qd5+s777w==",
|
|
||||||
"mode/php/php.min.js": "sha512-jZGz5n9AVTuQGhKTL0QzOm6bxxIQjaSbins+vD3OIdI7mtnmYE6h/L+UBGIp/SssLggbkxRzp9XkQNA4AyjFBw==",
|
|
||||||
"mode/markdown/markdown.min.js": "sha512-DmMao0nRIbyDjbaHc8fNd3kxGsZj9PCU6Iu/CeidLQT9Py8nYVA5n0PqXYmvqNdU+lCiTHOM/4E7bM/G8BttJg==",
|
|
||||||
"mode/python/python.min.js": "sha512-2M0GdbU5OxkGYMhakED69bw0c1pW3Nb0PeF3+9d+SnwN1ryPx3wiDdNqK3gSM7KAU/pEV+2tFJFbMKjKAahOkQ==",
|
|
||||||
"mode/sql/sql.min.js": "sha512-u8r8NUnG9B9L2dDmsfvs9ohQ0SO/Z7MB8bkdLxV7fE0Q8bOeP7/qft1D4KyE8HhVrpH3ihSrRoDiMbYR1VQBWQ==",
|
|
||||||
"mode/shell/shell.min.js": "sha512-HoC6JXgjHHevWAYqww37Gfu2c1G7SxAOv42wOakjR8csbTUfTB7OhVzSJ95LL62nII0RCyImp+7nR9zGmJ1wRQ==",
|
|
||||||
"mode/yaml/yaml.min.js": "sha512-+aXDZ93WyextRiAZpsRuJyiAZ38ztttUyO/H3FZx4gOAOv4/k9C6Um1CvHVtaowHZ2h7kH0d+orWvdBLPVwb4g==",
|
|
||||||
"mode/properties/properties.min.js": "sha512-P4OaO+QWj1wPRsdkEHlrgkx+a7qp6nUC8rI6dS/0/HPjHtlEmYfiambxowYa/UfqTxyNUnwTyPt5U6l1GO76yw==",
|
|
||||||
"mode/clike/clike.min.js": "sha512-l8ZIWnQ3XHPRG3MQ8+hT1OffRSTrFwrph1j1oc1Fzc9UKVGef5XN9fdO0vm3nW0PRgQ9LJgck6ciG59m69rvfg=="
|
|
||||||
};
|
|
||||||
|
|
||||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||||
|
|
||||||
function loadScriptOnce(url) {
|
function loadScriptOnce(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const key = `cm:${url}`;
|
const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9"
|
||||||
|
const withQS = url; //+ '?v=' + ver;
|
||||||
|
|
||||||
|
const key = `cm:${withQS}`;
|
||||||
let s = document.querySelector(`script[data-key="${key}"]`);
|
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||||
if (s) {
|
if (s) {
|
||||||
if (s.dataset.loaded === "1") return resolve();
|
if (s.dataset.loaded === "1") return resolve();
|
||||||
s.addEventListener("load", () => resolve());
|
s.addEventListener("load", resolve);
|
||||||
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s = document.createElement("script");
|
s = document.createElement("script");
|
||||||
s.src = url;
|
s.src = withQS;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.dataset.key = key;
|
s.dataset.key = key;
|
||||||
|
|
||||||
// 🔒 Add SRI if we have it
|
|
||||||
const relPath = url.replace(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/codemirror\/5\.65\.5\//, "");
|
|
||||||
const sri = MODE_SRI[relPath];
|
|
||||||
if (sri) {
|
|
||||||
s.integrity = sri;
|
|
||||||
s.crossOrigin = "anonymous";
|
|
||||||
// (Optional) further tighten referrer behavior:
|
|
||||||
// s.referrerPolicy = "no-referrer";
|
|
||||||
}
|
|
||||||
|
|
||||||
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||||
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,7 +102,7 @@ async function ensureModeLoaded(modeOption) {
|
|||||||
await ensureModeLoaded("htmlmixed");
|
await ensureModeLoaded("htmlmixed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadScriptOnce(CM_CDN + url);
|
await loadScriptOnce(CM_LOCAL + url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
updateRowHighlight,
|
updateRowHighlight,
|
||||||
toggleRowSelection,
|
toggleRowSelection,
|
||||||
attachEnterKeyListener
|
attachEnterKeyListener
|
||||||
} from './domUtils.js';
|
} from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { bindFileListContextMenu } from './fileMenu.js';
|
import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
|
||||||
import { openDownloadModal } from './fileActions.js';
|
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import {
|
import {
|
||||||
getParentFolder,
|
getParentFolder,
|
||||||
updateBreadcrumbTitle,
|
updateBreadcrumbTitle,
|
||||||
@@ -24,13 +24,13 @@ import {
|
|||||||
hideFolderManagerContextMenu,
|
hideFolderManagerContextMenu,
|
||||||
openRenameFolderModal,
|
openRenameFolderModal,
|
||||||
openDeleteFolderModal
|
openDeleteFolderModal
|
||||||
} from './folderManager.js';
|
} from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { openFolderShareModal } from './folderShareModal.js';
|
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
|
||||||
import {
|
import {
|
||||||
folderDragOverHandler,
|
folderDragOverHandler,
|
||||||
folderDragLeaveHandler,
|
folderDragLeaveHandler,
|
||||||
folderDropHandler
|
folderDropHandler
|
||||||
} from './fileDragDrop.js';
|
} from './fileDragDrop.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export let fileData = [];
|
export let fileData = [];
|
||||||
export let sortOrder = { column: "uploaded", ascending: true };
|
export let sortOrder = { column: "uploaded", ascending: true };
|
||||||
@@ -750,7 +750,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", async e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const m = await import('./fileEditor.js');
|
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||||||
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -759,7 +759,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", async e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const m = await import('./fileActions.js');
|
const m = await import('./fileActions.js?v={{APP_QVER}}');
|
||||||
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -768,7 +768,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", async e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const m = await import('./filePreview.js');
|
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||||||
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -822,7 +822,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
const fileName = this.getAttribute("data-file");
|
const fileName = this.getAttribute("data-file");
|
||||||
const file = fileData.find(f => f.name === fileName);
|
const file = fileData.find(f => f.name === fileName);
|
||||||
if (file) {
|
if (file) {
|
||||||
import('./filePreview.js').then(module => {
|
import('./filePreview.js?v={{APP_QVER}}').then(module => {
|
||||||
module.openShareModal(file, folder);
|
module.openShareModal(file, folder);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -831,7 +831,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||||
row.setAttribute("draggable", "true");
|
row.setAttribute("draggable", "true");
|
||||||
import('./fileDragDrop.js').then(module => {
|
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||||
row.addEventListener("dragstart", module.fileDragStartHandler);
|
row.addEventListener("dragstart", module.fileDragStartHandler);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1085,7 +1085,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
// preview clicks (dynamic import to avoid global dependency)
|
// preview clicks (dynamic import to avoid global dependency)
|
||||||
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||||||
el.addEventListener("click", async () => {
|
el.addEventListener("click", async () => {
|
||||||
const m = await import('./filePreview.js');
|
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||||||
m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1102,7 +1102,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", async e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const m = await import('./fileEditor.js');
|
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||||||
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1111,7 +1111,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", async e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const m = await import('./fileActions.js');
|
const m = await import('./fileActions.js?v={{APP_QVER}}');
|
||||||
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1123,7 +1123,7 @@ function wireSelectAll(fileListContent) {
|
|||||||
const fileName = btn.dataset.file;
|
const fileName = btn.dataset.file;
|
||||||
const fileObj = fileData.find(f => f.name === fileName);
|
const fileObj = fileData.find(f => f.name === fileName);
|
||||||
if (fileObj) {
|
if (fileObj) {
|
||||||
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
|
import('./filePreview.js?v={{APP_QVER}}').then(m => m.openShareModal(fileObj, folder));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// fileManager.js
|
// fileManager.js
|
||||||
import './fileListView.js';
|
import './fileListView.js?v={{APP_QVER}}';
|
||||||
import './filePreview.js';
|
import './filePreview.js?v={{APP_QVER}}';
|
||||||
import './fileEditor.js';
|
import './fileEditor.js?v={{APP_QVER}}';
|
||||||
import './fileDragDrop.js';
|
import './fileDragDrop.js?v={{APP_QVER}}';
|
||||||
import './fileMenu.js';
|
import './fileMenu.js?v={{APP_QVER}}';
|
||||||
import { initFileActions } from './fileActions.js';
|
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// Initialize file action buttons.
|
// Initialize file action buttons.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
// Attach folder drag-and-drop support for folder tree nodes.
|
// Attach folder drag-and-drop support for folder tree nodes.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
document.querySelectorAll(".folder-option").forEach(el => {
|
document.querySelectorAll(".folder-option").forEach(el => {
|
||||||
import('./fileDragDrop.js').then(module => {
|
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||||
el.addEventListener("dragover", module.folderDragOverHandler);
|
el.addEventListener("dragover", module.folderDragOverHandler);
|
||||||
el.addEventListener("dragleave", module.folderDragLeaveHandler);
|
el.addEventListener("dragleave", module.folderDragLeaveHandler);
|
||||||
el.addEventListener("drop", module.folderDropHandler);
|
el.addEventListener("drop", module.folderDropHandler);
|
||||||
@@ -32,7 +32,7 @@ document.addEventListener("keydown", function(e) {
|
|||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||||
if (selectedCheckboxes.length > 0) {
|
if (selectedCheckboxes.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
import('./fileActions.js').then(module => {
|
import('./fileActions.js?v={{APP_QVER}}').then(module => {
|
||||||
module.handleDeleteSelected(new Event("click"));
|
module.handleDeleteSelected(new Event("click"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
|
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { previewFile } from './filePreview.js';
|
import { previewFile } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { editFile } from './fileEditor.js';
|
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||||
import { canEditFile, fileData } from './fileListView.js';
|
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function showFileContextMenu(x, y, menuItems) {
|
export function showFileContextMenu(x, y, menuItems) {
|
||||||
let menu = document.getElementById("fileContextMenu");
|
let menu = document.getElementById("fileContextMenu");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// filePreview.js
|
// filePreview.js
|
||||||
import { escapeHTML, showToast } from './domUtils.js';
|
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { fileData } from './fileListView.js';
|
import { fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function openShareModal(file, folder) {
|
export function openShareModal(file, folder) {
|
||||||
// Remove any existing modal
|
// Remove any existing modal
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
// adding tags to files (with a global tag store for reuse),
|
// adding tags to files (with a global tag store for reuse),
|
||||||
// updating the file row display with tag badges,
|
// updating the file row display with tag badges,
|
||||||
// filtering the file list by tag, and persisting tag data.
|
// filtering the file list by tag, and persisting tag data.
|
||||||
import { escapeHTML } from './domUtils.js';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { renderFileTable, renderGalleryView } from './fileListView.js';
|
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function openTagModal(file) {
|
export function openTagModal(file) {
|
||||||
// Create the modal element.
|
// Create the modal element.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// folderManager.js
|
// folderManager.js
|
||||||
|
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { openFolderShareModal } from './folderShareModal.js';
|
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
|
||||||
import { fetchWithCsrf } from './auth.js';
|
import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
|
||||||
import { loadCsrfToken } from './main.js';
|
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
Helpers: safe JSON + state
|
Helpers: safe JSON + state
|
||||||
@@ -103,6 +103,7 @@ async function applyFolderCapabilities(folder) {
|
|||||||
|
|
||||||
const isRoot = (folder === 'root');
|
const isRoot = (folder === 'root');
|
||||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||||
|
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
|
||||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||||
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||||
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||||
@@ -180,6 +181,49 @@ function breadcrumbDropHandler(e) {
|
|||||||
console.error("Invalid drag data on breadcrumb:", err);
|
console.error("Invalid drag data on breadcrumb:", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* FOLDER MOVE FALLBACK */
|
||||||
|
if (!dragData) {
|
||||||
|
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||||
|
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||||
|
if (plain) {
|
||||||
|
const sourceFolder = String(plain).trim();
|
||||||
|
if (sourceFolder && sourceFolder !== "root") {
|
||||||
|
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||||
|
showToast("Invalid destination.", 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||||
|
})
|
||||||
|
.then(safeJson)
|
||||||
|
.then(data => {
|
||||||
|
if (data && !data.error) {
|
||||||
|
showToast(`Folder moved to ${dropFolder}!`);
|
||||||
|
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||||
|
const base = sourceFolder.split("/").pop();
|
||||||
|
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||||
|
window.currentFolder = newPath;
|
||||||
|
}
|
||||||
|
return loadFolderTree().then(() => {
|
||||||
|
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||||
|
loadFileList(window.currentFolder || "root");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error moving folder:", err);
|
||||||
|
showToast("Error moving folder", 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||||
if (filesToMove.length === 0) return;
|
if (filesToMove.length === 0) return;
|
||||||
|
|
||||||
@@ -262,7 +306,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
|||||||
} else {
|
} else {
|
||||||
html += `<span class="folder-indent-placeholder"></span>`;
|
html += `<span class="folder-indent-placeholder"></span>`;
|
||||||
}
|
}
|
||||||
html += `<span class="folder-option" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||||
}
|
}
|
||||||
@@ -312,13 +356,58 @@ function folderDropHandler(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove("drop-hover");
|
||||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||||
let dragData;
|
let dragData = null;
|
||||||
try {
|
try {
|
||||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
const jsonStr = event.dataTransfer.getData("application/json") || "";
|
||||||
} catch (e) {
|
if (jsonStr) dragData = JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
console.error("Invalid drag data", e);
|
console.error("Invalid drag data", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* FOLDER MOVE FALLBACK */
|
||||||
|
if (!dragData) {
|
||||||
|
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||||
|
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||||
|
if (plain) {
|
||||||
|
const sourceFolder = String(plain).trim();
|
||||||
|
if (sourceFolder && sourceFolder !== "root") {
|
||||||
|
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||||
|
showToast("Invalid destination.", 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||||
|
})
|
||||||
|
.then(safeJson)
|
||||||
|
.then(data => {
|
||||||
|
if (data && !data.error) {
|
||||||
|
showToast(`Folder moved to ${dropFolder}!`);
|
||||||
|
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||||
|
const base = sourceFolder.split("/").pop();
|
||||||
|
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||||
|
window.currentFolder = newPath;
|
||||||
|
}
|
||||||
|
return loadFolderTree().then(() => {
|
||||||
|
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||||
|
loadFileList(window.currentFolder || "root");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error moving folder:", err);
|
||||||
|
showToast("Error moving folder", 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||||
if (filesToMove.length === 0) return;
|
if (filesToMove.length === 0) return;
|
||||||
|
|
||||||
@@ -459,6 +548,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
// Attach drag/drop event listeners.
|
// Attach drag/drop event listeners.
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
container.querySelectorAll(".folder-option").forEach(el => {
|
||||||
|
// Provide folder path payload for folder->folder DnD
|
||||||
|
el.addEventListener("dragstart", (ev) => {
|
||||||
|
const src = el.getAttribute("data-folder");
|
||||||
|
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||||
|
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
|
||||||
el.addEventListener("dragover", folderDragOverHandler);
|
el.addEventListener("dragover", folderDragOverHandler);
|
||||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||||
el.addEventListener("drop", folderDropHandler);
|
el.addEventListener("drop", folderDropHandler);
|
||||||
@@ -487,6 +584,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
// Folder-option click: update selection, breadcrumbs, and file list
|
// Folder-option click: update selection, breadcrumbs, and file list
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
container.querySelectorAll(".folder-option").forEach(el => {
|
||||||
|
// Provide folder path payload for folder->folder DnD
|
||||||
|
el.addEventListener("dragstart", (ev) => {
|
||||||
|
const src = el.getAttribute("data-folder");
|
||||||
|
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||||
|
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
|
||||||
el.addEventListener("click", function (e) {
|
el.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||||
@@ -642,6 +747,44 @@ if (submitRename) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Move Folder Modal helper (shared by button + context menu) ===
|
||||||
|
function openMoveFolderUI(sourceFolder) {
|
||||||
|
const modal = document.getElementById('moveFolderModal');
|
||||||
|
const targetSel = document.getElementById('moveFolderTarget');
|
||||||
|
|
||||||
|
// If you right-clicked a different folder than currently selected, use that
|
||||||
|
if (sourceFolder && sourceFolder !== 'root') {
|
||||||
|
window.currentFolder = sourceFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill target dropdown
|
||||||
|
if (targetSel) {
|
||||||
|
targetSel.innerHTML = '';
|
||||||
|
fetch('/api/folder/getFolderList.php', { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(list => {
|
||||||
|
if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) {
|
||||||
|
list = list.map(it => it.folder);
|
||||||
|
}
|
||||||
|
// Root option
|
||||||
|
const rootOpt = document.createElement('option');
|
||||||
|
rootOpt.value = 'root'; rootOpt.textContent = '(Root)';
|
||||||
|
targetSel.appendChild(rootOpt);
|
||||||
|
|
||||||
|
(list || [])
|
||||||
|
.filter(f => f && f !== 'trash' && f !== (window.currentFolder || ''))
|
||||||
|
.forEach(f => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = f; o.textContent = f;
|
||||||
|
targetSel.appendChild(o);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(()=>{ /* no-op */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modal) modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
export function openDeleteFolderModal() {
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
@@ -841,6 +984,10 @@ function folderManagerContextMenuHandler(e) {
|
|||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("move_folder"),
|
||||||
|
action: () => { openMoveFolderUI(folder); }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("rename_folder"),
|
label: t("rename_folder"),
|
||||||
action: () => { openRenameFolderModal(); }
|
action: () => { openRenameFolderModal(); }
|
||||||
@@ -923,4 +1070,53 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initial context menu delegation bind
|
// Initial context menu delegation bind
|
||||||
bindFolderManagerContextMenu();
|
bindFolderManagerContextMenu();
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const moveBtn = document.getElementById('moveFolderBtn');
|
||||||
|
const modal = document.getElementById('moveFolderModal');
|
||||||
|
const targetSel = document.getElementById('moveFolderTarget');
|
||||||
|
const cancelBtn = document.getElementById('cancelMoveFolder');
|
||||||
|
const confirmBtn= document.getElementById('confirmMoveFolder');
|
||||||
|
|
||||||
|
if (moveBtn) {
|
||||||
|
moveBtn.addEventListener('click', () => {
|
||||||
|
const cf = window.currentFolder || 'root';
|
||||||
|
if (!cf || cf === 'root') { showToast('Select a non-root folder to move.'); return; }
|
||||||
|
openMoveFolderUI(cf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) cancelBtn.addEventListener('click', () => { if (modal) modal.style.display = 'none'; });
|
||||||
|
|
||||||
|
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
|
||||||
|
if (!targetSel) return;
|
||||||
|
const destination = targetSel.value;
|
||||||
|
const source = window.currentFolder;
|
||||||
|
|
||||||
|
if (!destination) { showToast('Pick a destination'); return; }
|
||||||
|
if (destination === source || (destination + '/').startsWith(source + '/')) {
|
||||||
|
showToast('Invalid destination'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/folder/moveFolder.php', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
|
||||||
|
body: JSON.stringify({ source, destination })
|
||||||
|
});
|
||||||
|
const data = await safeJson(res);
|
||||||
|
if (res.ok && data && !data.error) {
|
||||||
|
showToast('Folder moved');
|
||||||
|
if (modal) modal.style.display='none';
|
||||||
|
await loadFolderTree();
|
||||||
|
const base = source.split('/').pop();
|
||||||
|
const newPath = (destination === 'root' ? '' : destination + '/') + base;
|
||||||
|
window.currentFolder = newPath;
|
||||||
|
loadFileList(window.currentFolder || 'root');
|
||||||
|
} else {
|
||||||
|
showToast('Error: ' + (data && data.error || 'Move failed'));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); showToast('Move failed'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// js/folderShareModal.js
|
// js/folderShareModal.js
|
||||||
import { escapeHTML, showToast } from './domUtils.js';
|
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function openFolderShareModal(folder) {
|
export function openFolderShareModal(folder) {
|
||||||
// Remove any existing modal
|
// Remove any existing modal
|
||||||
|
|||||||
@@ -282,7 +282,27 @@ const translations = {
|
|||||||
"bypass_ownership": "Bypass Ownership",
|
"bypass_ownership": "Bypass Ownership",
|
||||||
"error_loading_user_grants": "Error loading user grants",
|
"error_loading_user_grants": "Error loading user grants",
|
||||||
"click_to_edit": "Click to edit",
|
"click_to_edit": "Click to edit",
|
||||||
"folder_access": "Folder Access"
|
"folder_access": "Folder Access",
|
||||||
|
"move_folder": "Move Folder",
|
||||||
|
"move_folder_message": "Select a destination folder to move this folder to:",
|
||||||
|
"move_folder_title": "Move this folder",
|
||||||
|
"move_folder_success": "Folder moved successfully.",
|
||||||
|
"move_folder_error": "Error moving folder.",
|
||||||
|
"move_folder_invalid": "Invalid source or destination folder.",
|
||||||
|
"move_folder_denied": "You do not have permission to move this folder.",
|
||||||
|
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
|
||||||
|
"move_folder_same_owner": "Source and destination must have the same owner.",
|
||||||
|
"move_folder_confirm": "Are you sure you want to move this folder?",
|
||||||
|
"move_folder_select_dest": "Select a destination folder",
|
||||||
|
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
|
||||||
|
"acl_move_folder_label": "Move Folder (source)",
|
||||||
|
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
|
||||||
|
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
|
||||||
|
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
|
||||||
|
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||||
|
"context_move_folder": "Move Folder...",
|
||||||
|
"context_move_here": "Move Here",
|
||||||
|
"context_move_cancel": "Cancel Move"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -1,55 +1,65 @@
|
|||||||
import { sendRequest } from './networkUtils.js';
|
// /js/main.js
|
||||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { initUpload } from './upload.js';
|
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||||
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||||
import { displayFilePreview } from './filePreview.js';
|
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload } from './fileActions.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { editFile, saveFile } from './fileEditor.js';
|
import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { editFile, saveFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||||
|
import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
// NEW: import shared helpers from appCore (moved out of main.js)
|
||||||
|
import {
|
||||||
|
initializeApp,
|
||||||
|
loadCsrfToken,
|
||||||
|
triggerLogout,
|
||||||
|
setCsrfToken,
|
||||||
|
getCsrfToken
|
||||||
|
} from './appCore.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
CSRF HOTFIX UTILITIES
|
CSRF HOTFIX UTILITIES
|
||||||
========================= */
|
========================= */
|
||||||
const _nativeFetch = window.fetch; // keep the real fetch
|
// Keep a handle to the native fetch so wrappers never recurse
|
||||||
|
const _nativeFetch = window.fetch.bind(window);
|
||||||
function setCsrfToken(token) {
|
|
||||||
if (!token) return;
|
|
||||||
window.csrfToken = token;
|
|
||||||
localStorage.setItem('csrf', token);
|
|
||||||
|
|
||||||
// meta tag for easy access in other places
|
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement('meta');
|
|
||||||
meta.name = 'csrf-token';
|
|
||||||
document.head.appendChild(meta);
|
|
||||||
}
|
|
||||||
meta.content = token;
|
|
||||||
}
|
|
||||||
function getCsrfToken() {
|
|
||||||
return window.csrfToken || localStorage.getItem('csrf') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed CSRF from storage ASAP (before any requests)
|
// Seed CSRF from storage ASAP (before any requests)
|
||||||
setCsrfToken(getCsrfToken());
|
setCsrfToken(getCsrfToken());
|
||||||
|
|
||||||
// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers.
|
// Wrap fetch so *all* callers get CSRF header + token rotation, without recursion
|
||||||
async function fetchWithCsrfAndRefresh(input, init = {}) {
|
async function fetchWithCsrfAndRefresh(input, init = {}) {
|
||||||
const res = await fetchWithCsrf(input, init);
|
const headers = new Headers(init?.headers || {});
|
||||||
|
const token = getCsrfToken();
|
||||||
|
|
||||||
|
if (token && !headers.has('X-CSRF-Token')) {
|
||||||
|
headers.set('X-CSRF-Token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await _nativeFetch(input, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rotated = res.headers?.get('X-CSRF-Token');
|
const rotated = res.headers?.get('X-CSRF-Token');
|
||||||
if (rotated) setCsrfToken(rotated);
|
if (rotated) setCsrfToken(rotated);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace global fetch with the wrapped version so *all* callers benefit.
|
// Avoid double-wrapping if this module re-evaluates for any reason
|
||||||
window.fetch = fetchWithCsrfAndRefresh;
|
if (!window.fetch || !window.fetch._frWrapped) {
|
||||||
|
const wrapped = fetchWithCsrfAndRefresh;
|
||||||
|
Object.defineProperty(wrapped, '_frWrapped', { value: true });
|
||||||
|
window.fetch = wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
SAFE API HELPERS
|
SAFE API HELPERS
|
||||||
@@ -84,6 +94,7 @@ export async function apiPOSTJSON(url, body, opts = {}) {
|
|||||||
// Optional: expose on window for legacy callers
|
// Optional: expose on window for legacy callers
|
||||||
window.apiGETJSON = apiGETJSON;
|
window.apiGETJSON = apiGETJSON;
|
||||||
window.apiPOSTJSON = apiPOSTJSON;
|
window.apiPOSTJSON = apiPOSTJSON;
|
||||||
|
window.triggerLogout = triggerLogout; // expose the moved helper
|
||||||
|
|
||||||
// Global handler to keep UX friendly if something forgets to catch
|
// Global handler to keep UX friendly if something forgets to catch
|
||||||
window.addEventListener("unhandledrejection", (ev) => {
|
window.addEventListener("unhandledrejection", (ev) => {
|
||||||
@@ -98,129 +109,16 @@ window.addEventListener("unhandledrejection", (ev) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
APP INIT
|
BOOTSTRAP
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
export function initializeApp() {
|
|
||||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
|
||||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
|
||||||
|
|
||||||
window.currentFolder = "root";
|
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
|
||||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
|
||||||
loadAdminConfigFunc();
|
|
||||||
initTagSearch();
|
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
|
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
|
||||||
if (fileListArea && uploadArea) {
|
|
||||||
fileListArea.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
fileListArea.classList.add('drop-hover');
|
|
||||||
});
|
|
||||||
fileListArea.addEventListener('dragleave', () => {
|
|
||||||
fileListArea.classList.remove('drop-hover');
|
|
||||||
});
|
|
||||||
fileListArea.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
fileListArea.classList.remove('drop-hover');
|
|
||||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
|
||||||
dataTransfer: e.dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initDragAndDrop();
|
|
||||||
loadSidebarOrder();
|
|
||||||
loadHeaderOrder();
|
|
||||||
initFileActions();
|
|
||||||
initUpload();
|
|
||||||
loadFolderTree();
|
|
||||||
// Only run trash/restore for admins
|
|
||||||
const isAdmin =
|
|
||||||
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
|
||||||
if (isAdmin) {
|
|
||||||
setupTrashRestoreDelete();
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap/refresh CSRF from the server.
|
|
||||||
* Uses the *native* fetch to avoid any wrapper loops and to work even if we don't
|
|
||||||
* yet have a token. Also accepts a rotated token from the response header.
|
|
||||||
*/
|
|
||||||
export function loadCsrfToken() {
|
|
||||||
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
|
|
||||||
.then(async res => {
|
|
||||||
// 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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Immediately clear “?logout=1” flag
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('logout') === '1') {
|
if (params.get('logout') === '1') {
|
||||||
localStorage.removeItem("username");
|
localStorage.removeItem("username");
|
||||||
localStorage.removeItem("userTOTPEnabled");
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerLogout() {
|
|
||||||
_nativeFetch("/api/auth/logout.php", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "X-CSRF-Token": getCsrfToken() }
|
|
||||||
})
|
|
||||||
.then(() => window.location.reload(true))
|
|
||||||
.catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose functions for inline handlers.
|
|
||||||
window.sendRequest = sendRequest;
|
|
||||||
window.toggleVisibility = toggleVisibility;
|
|
||||||
window.toggleAllCheckboxes = toggleAllCheckboxes;
|
|
||||||
window.editFile = editFile;
|
|
||||||
window.saveFile = saveFile;
|
|
||||||
window.renameFile = renameFile;
|
|
||||||
window.confirmSingleDownload = confirmSingleDownload;
|
|
||||||
window.openDownloadModal = openDownloadModal;
|
|
||||||
|
|
||||||
// Global variable for the current folder.
|
|
||||||
window.currentFolder = "root";
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
// Load admin config early
|
// Load site config early (safe subset)
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
@@ -301,4 +199,17 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
window.scrollBy(0, SCROLL_SPEED);
|
window.scrollBy(0, SCROLL_SPEED);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose functions for inline handlers
|
||||||
|
window.sendRequest = sendRequest;
|
||||||
|
window.toggleVisibility = toggleVisibility;
|
||||||
|
window.toggleAllCheckboxes = toggleAllCheckboxes;
|
||||||
|
window.editFile = editFile;
|
||||||
|
window.saveFile = saveFile;
|
||||||
|
window.renameFile = renameFile;
|
||||||
|
window.confirmSingleDownload = confirmSingleDownload;
|
||||||
|
window.openDownloadModal = openDownloadModal;
|
||||||
|
|
||||||
|
// Global variable for the current folder (initial default; initializeApp will update)
|
||||||
|
window.currentFolder = "root";
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// trashRestoreDelete.js
|
// trashRestoreDelete.js
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { toggleVisibility, showToast } from './domUtils.js';
|
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
function showConfirm(message, onConfirm) {
|
function showConfirm(message, onConfirm) {
|
||||||
const modal = document.getElementById("customConfirmModal");
|
const modal = document.getElementById("customConfirmModal");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { initFileActions } from './fileActions.js';
|
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { displayFilePreview } from './filePreview.js';
|
import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { showToast, escapeHTML } from './domUtils.js';
|
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||||
@@ -161,91 +161,91 @@ function createFileEntry(file) {
|
|||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.classList.add("remove-file-btn");
|
removeBtn.classList.add("remove-file-btn");
|
||||||
removeBtn.textContent = "×";
|
removeBtn.textContent = "×";
|
||||||
// In your remove button event listener, replace the fetch call with:
|
// In your remove button event listener, replace the fetch call with:
|
||||||
removeBtn.addEventListener("click", function (e) {
|
removeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const uploadIndex = file.uploadIndex;
|
const uploadIndex = file.uploadIndex;
|
||||||
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
||||||
|
|
||||||
// Cancel the file upload if possible.
|
// Cancel the file upload if possible.
|
||||||
if (typeof file.cancel === "function") {
|
if (typeof file.cancel === "function") {
|
||||||
file.cancel();
|
file.cancel();
|
||||||
console.log("Canceled file upload:", file.fileName);
|
console.log("Canceled file upload:", file.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove file from the resumable queue.
|
// Remove file from the resumable queue.
|
||||||
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
||||||
resumableInstance.removeFile(file);
|
resumableInstance.removeFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call our helper repeatedly to remove the chunk folder.
|
// Call our helper repeatedly to remove the chunk folder.
|
||||||
if (file.uniqueIdentifier) {
|
if (file.uniqueIdentifier) {
|
||||||
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
li.remove();
|
li.remove();
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
});
|
});
|
||||||
li.removeBtn = removeBtn;
|
li.removeBtn = removeBtn;
|
||||||
li.appendChild(removeBtn);
|
li.appendChild(removeBtn);
|
||||||
|
|
||||||
// Add pause/resume/restart button if the file supports pause/resume.
|
// Add pause/resume/restart button if the file supports pause/resume.
|
||||||
// Conditionally add the pause/resume button only if file.pause is available
|
// Conditionally add the pause/resume button only if file.pause is available
|
||||||
// Pause/Resume button (for resumable file–picker uploads)
|
// Pause/Resume button (for resumable file–picker uploads)
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
const pauseResumeBtn = document.createElement("button");
|
const pauseResumeBtn = document.createElement("button");
|
||||||
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
||||||
pauseResumeBtn.classList.add("pause-resume-btn");
|
pauseResumeBtn.classList.add("pause-resume-btn");
|
||||||
// Start with pause icon and disable button until upload starts
|
// Start with pause icon and disable button until upload starts
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.disabled = true;
|
pauseResumeBtn.disabled = true;
|
||||||
pauseResumeBtn.addEventListener("click", function (e) {
|
pauseResumeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (file.isError) {
|
if (file.isError) {
|
||||||
// If the file previously failed, try restarting upload.
|
// If the file previously failed, try restarting upload.
|
||||||
if (typeof file.retry === "function") {
|
if (typeof file.retry === "function") {
|
||||||
file.retry();
|
file.retry();
|
||||||
file.isError = false;
|
file.isError = false;
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
}
|
}
|
||||||
} else if (!file.paused) {
|
} else if (!file.paused) {
|
||||||
// Pause the upload (if possible)
|
// Pause the upload (if possible)
|
||||||
if (typeof file.pause === "function") {
|
|
||||||
file.pause();
|
|
||||||
file.paused = true;
|
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
} else if (file.paused) {
|
|
||||||
// Resume sequence: first call to resume (or upload() fallback)
|
|
||||||
if (typeof file.resume === "function") {
|
|
||||||
file.resume();
|
|
||||||
} else {
|
|
||||||
resumableInstance.upload();
|
|
||||||
}
|
|
||||||
// After a short delay, pause again then resume
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.pause();
|
file.pause();
|
||||||
|
file.paused = true;
|
||||||
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
} else if (file.paused) {
|
||||||
|
// Resume sequence: first call to resume (or upload() fallback)
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
// After a short delay, pause again then resume
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof file.resume === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.resume();
|
file.pause();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
|
} else {
|
||||||
|
resumableInstance.upload();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, 100);
|
file.paused = false;
|
||||||
file.paused = false;
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
} else {
|
||||||
} else {
|
console.error("Pause/resume function not available for file", file);
|
||||||
console.error("Pause/resume function not available for file", file);
|
}
|
||||||
}
|
});
|
||||||
});
|
li.appendChild(pauseResumeBtn);
|
||||||
li.appendChild(pauseResumeBtn);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Preview element
|
// Preview element
|
||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
@@ -406,20 +406,27 @@ let resumableInstance;
|
|||||||
function initResumableUpload() {
|
function initResumableUpload() {
|
||||||
resumableInstance = new Resumable({
|
resumableInstance = new Resumable({
|
||||||
target: "/api/upload/upload.php",
|
target: "/api/upload/upload.php",
|
||||||
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
|
|
||||||
simultaneousUploads: 3,
|
simultaneousUploads: 3,
|
||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: false,
|
||||||
throttleProgressCallbacks: 1,
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
query: {
|
query: () => ({
|
||||||
folder: window.currentFolder || "root",
|
folder: window.currentFolder || "root",
|
||||||
upload_token: window.csrfToken // still as a fallback
|
upload_token: window.csrfToken
|
||||||
}
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// keep query fresh when folder changes (call this from your folder nav code)
|
||||||
|
function updateResumableQuery() {
|
||||||
|
if (!resumableInstance) return;
|
||||||
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
|
// if you're not using a function for query, do:
|
||||||
|
resumableInstance.opts.query.folder = window.currentFolder || 'root';
|
||||||
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
// Assign Resumable to file input for file picker uploads.
|
// Assign Resumable to file input for file picker uploads.
|
||||||
@@ -432,6 +439,7 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resumableInstance.on("fileAdded", function (file) {
|
resumableInstance.on("fileAdded", function (file) {
|
||||||
|
|
||||||
// Initialize custom paused flag
|
// Initialize custom paused flag
|
||||||
file.paused = false;
|
file.paused = false;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -461,16 +469,17 @@ function initResumableUpload() {
|
|||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
updateResumableQuery();
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function(file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
const progress = file.progress(); // value between 0 and 1
|
const progress = file.progress(); // value between 0 and 1
|
||||||
const percent = Math.floor(progress * 100);
|
const percent = Math.floor(progress * 100);
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||||
if (li && li.progressBar) {
|
if (li && li.progressBar) {
|
||||||
if (percent < 99) {
|
if (percent < 99) {
|
||||||
li.progressBar.style.width = percent + "%";
|
li.progressBar.style.width = percent + "%";
|
||||||
|
|
||||||
// Calculate elapsed time and speed.
|
// Calculate elapsed time and speed.
|
||||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
let speed = "";
|
let speed = "";
|
||||||
@@ -491,7 +500,7 @@ function initResumableUpload() {
|
|||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable the pause/resume button once progress starts.
|
// Enable the pause/resume button once progress starts.
|
||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) {
|
if (pauseResumeBtn) {
|
||||||
@@ -499,8 +508,8 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function(file, message) {
|
resumableInstance.on("fileSuccess", function (file, message) {
|
||||||
// Try to parse JSON response
|
// Try to parse JSON response
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -508,18 +517,18 @@ function initResumableUpload() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Soft‐fail CSRF? then update token & retry this file
|
// 1) Soft‐fail CSRF? then update token & retry this file
|
||||||
if (data && data.csrf_expired) {
|
if (data && data.csrf_expired) {
|
||||||
// Update global and Resumable headers
|
// Update global and Resumable headers
|
||||||
window.csrfToken = data.csrf_token;
|
window.csrfToken = data.csrf_token;
|
||||||
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
||||||
resumableInstance.opts.query.upload_token = data.csrf_token;
|
resumableInstance.opts.query.upload_token = data.csrf_token;
|
||||||
// Retry this chunk/file
|
// Retry this chunk/file
|
||||||
file.retry();
|
file.retry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Otherwise treat as real success:
|
// 2) Otherwise treat as real success:
|
||||||
const li = document.querySelector(
|
const li = document.querySelector(
|
||||||
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||||
@@ -531,13 +540,13 @@ function initResumableUpload() {
|
|||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
||||||
const removeBtn = li.querySelector(".remove-file-btn");
|
const removeBtn = li.querySelector(".remove-file-btn");
|
||||||
if (removeBtn) removeBtn.style.display = "none";
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
setTimeout(() => li.remove(), 5000);
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
resumableInstance.on("fileError", function (file, message) {
|
resumableInstance.on("fileError", function (file, message) {
|
||||||
@@ -637,7 +646,7 @@ function submitFiles(allFiles) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
jsonResponse = null;
|
jsonResponse = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
||||||
if (jsonResponse && jsonResponse.csrf_expired) {
|
if (jsonResponse && jsonResponse.csrf_expired) {
|
||||||
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
||||||
@@ -650,10 +659,10 @@ function submitFiles(allFiles) {
|
|||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
// real success
|
// real success
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -662,6 +671,7 @@ function submitFiles(allFiles) {
|
|||||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = true;
|
uploadResults[file.uploadIndex] = true;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// real failure
|
// real failure
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -681,12 +691,17 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
}
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("error", function () {
|
xhr.addEventListener("error", function () {
|
||||||
@@ -699,6 +714,9 @@ function submitFiles(allFiles) {
|
|||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
// Immediate summary toast based on actual XHR outcomes
|
||||||
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
|
const failedCount = allFiles.length - succeededCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -725,17 +743,30 @@ function submitFiles(allFiles) {
|
|||||||
loadFileList(folderToUse)
|
loadFileList(folderToUse)
|
||||||
.then(serverFiles => {
|
.then(serverFiles => {
|
||||||
initFileActions();
|
initFileActions();
|
||||||
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
|
// Be tolerant to API shapes: string or object with name/fileName/filename
|
||||||
|
serverFiles = (serverFiles || [])
|
||||||
|
.map(item => {
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
|
||||||
|
return String(n);
|
||||||
|
})
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
let overallSuccess = true;
|
let overallSuccess = true;
|
||||||
|
let succeeded = 0;
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const clientFileName = file.name.trim().toLowerCase();
|
const clientFileName = file.name.trim().toLowerCase();
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
|
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
||||||
|
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
|
||||||
if (li) {
|
if (li) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
overallSuccess = false;
|
overallSuccess = false;
|
||||||
|
|
||||||
} else if (li) {
|
} else if (li) {
|
||||||
|
succeeded++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
li.remove();
|
li.remove();
|
||||||
@@ -757,9 +788,12 @@ function submitFiles(allFiles) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!overallSuccess) {
|
if (!overallSuccess) {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
const failed = allFiles.length - succeeded;
|
||||||
|
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
||||||
|
} else {
|
||||||
|
showToast(`${succeeded} file succeeded. Please check the list.`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -768,6 +802,7 @@ function submitFiles(allFiles) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
public/js/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// generated by CI
|
||||||
|
window.APP_VERSION = 'v1.7.2';
|
||||||
21
public/vendor/bootstrap/4.5.2/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Bootstrap 4.5.2 — MIT
|
||||||
|
|
||||||
|
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.
|
||||||
6
public/vendor/bootstrap/4.5.2/bootstrap.min.css
vendored
Normal file
1
public/vendor/bootstrap/4.5.2/bootstrap.min.css.map.json
vendored
Normal file
21
public/vendor/codemirror/5.65.5/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CodeMirror 5.65.5 — MIT
|
||||||
|
|
||||||
|
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.
|
||||||
1
public/vendor/codemirror/5.65.5/codemirror.min.css
vendored
Normal file
1
public/vendor/codemirror/5.65.5/codemirror.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/clike/clike.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/css/css.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/htmlmixed/htmlmixed.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
|
||||||
1
public/vendor/codemirror/5.65.5/mode/javascript/javascript.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/markdown/markdown.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/php/php.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/properties/properties.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});
|
||||||
1
public/vendor/codemirror/5.65.5/mode/python/python.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/shell/shell.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});
|
||||||
1
public/vendor/codemirror/5.65.5/mode/sql/sql.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/xml/xml.min.js
vendored
Normal file
1
public/vendor/codemirror/5.65.5/mode/yaml/yaml.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});
|
||||||
1
public/vendor/codemirror/5.65.5/theme/material-darker.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.cm-s-material-darker.CodeMirror{background-color:#212121;color:#eff}.cm-s-material-darker .CodeMirror-gutters{background:#212121;color:#545454;border:none}.cm-s-material-darker .CodeMirror-guttermarker,.cm-s-material-darker .CodeMirror-guttermarker-subtle,.cm-s-material-darker .CodeMirror-linenumber{color:#545454}.cm-s-material-darker .CodeMirror-cursor{border-left:1px solid #fc0}.cm-s-material-darker div.CodeMirror-selected{background:rgba(97,97,97,.2)}.cm-s-material-darker.CodeMirror-focused div.CodeMirror-selected{background:rgba(97,97,97,.2)}.cm-s-material-darker .CodeMirror-line::selection,.cm-s-material-darker .CodeMirror-line>span::selection,.cm-s-material-darker .CodeMirror-line>span>span::selection{background:rgba(128,203,196,.2)}.cm-s-material-darker .CodeMirror-line::-moz-selection,.cm-s-material-darker .CodeMirror-line>span::-moz-selection,.cm-s-material-darker .CodeMirror-line>span>span::-moz-selection{background:rgba(128,203,196,.2)}.cm-s-material-darker .CodeMirror-activeline-background{background:rgba(0,0,0,.5)}.cm-s-material-darker .cm-keyword{color:#c792ea}.cm-s-material-darker .cm-operator{color:#89ddff}.cm-s-material-darker .cm-variable-2{color:#eff}.cm-s-material-darker .cm-type,.cm-s-material-darker .cm-variable-3{color:#f07178}.cm-s-material-darker .cm-builtin{color:#ffcb6b}.cm-s-material-darker .cm-atom{color:#f78c6c}.cm-s-material-darker .cm-number{color:#ff5370}.cm-s-material-darker .cm-def{color:#82aaff}.cm-s-material-darker .cm-string{color:#c3e88d}.cm-s-material-darker .cm-string-2{color:#f07178}.cm-s-material-darker .cm-comment{color:#545454}.cm-s-material-darker .cm-variable{color:#f07178}.cm-s-material-darker .cm-tag{color:#ff5370}.cm-s-material-darker .cm-meta{color:#ffcb6b}.cm-s-material-darker .cm-attribute{color:#c792ea}.cm-s-material-darker .cm-property{color:#c792ea}.cm-s-material-darker .cm-qualifier{color:#decb6b}.cm-s-material-darker .cm-type,.cm-s-material-darker .cm-variable-3{color:#decb6b}.cm-s-material-darker .cm-error{color:#fff;background-color:#ff5370}.cm-s-material-darker .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
|
||||||
180
public/vendor/dompurify/2.4.0/LICENSE
vendored
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
DOMPurify 2.4.0 — Apache-2.0
|
||||||
|
|
||||||
|
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.
|
||||||
2
public/vendor/dompurify/2.4.0/purify.min.js
vendored
Normal file
1
public/vendor/dompurify/2.4.0/purify.min.js-2.map
generated
vendored
Normal file
180
public/vendor/fuse/6.6.2/LICENSE
vendored
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
Fuse.js 6.6.2 — Apache-2.0
|
||||||
|
|
||||||
|
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.
|
||||||
9
public/vendor/fuse/6.6.2/fuse.min.js
vendored
Normal file
21
public/vendor/redoc/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present, Rebilly, Inc.
|
||||||
|
|
||||||
|
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.
|
||||||
1832
public/vendor/redoc/redoc.standalone.js
vendored
Normal file
21
public/vendor/resumable/1.1.0/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Resumable.js 1.1.0 — MIT
|
||||||
|
|
||||||
|
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.
|
||||||
1
public/vendor/resumable/1.1.0/resumable.min.js
vendored
Normal file
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 764 KiB After Width: | Height: | Size: 470 KiB |
BIN
resources/dark-folder-access.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 608 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 554 KiB |
54
scripts/stamp-assets.sh
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# usage: scripts/stamp-assets.sh vX.Y.Z /path/to/target/dir
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VER="${1:?usage: stamp-assets.sh vX.Y.Z target_dir}"
|
||||||
|
QVER="${VER#v}"
|
||||||
|
TARGET="${2:-.}"
|
||||||
|
|
||||||
|
echo "Stamping assets in: $TARGET"
|
||||||
|
echo "VER=${VER} QVER=${QVER}"
|
||||||
|
|
||||||
|
cd "$TARGET"
|
||||||
|
|
||||||
|
# Normalize CRLF to LF (if any files were edited on Windows)
|
||||||
|
# We only touch web assets.
|
||||||
|
find public \( -name '*.html' -o -name '*.php' -o -name '*.css' -o -name '*.js' \) -type f -print0 \
|
||||||
|
| xargs -0 -r sed -i 's/\r$//'
|
||||||
|
|
||||||
|
# --- HTML/CSS/PHP: stamp ?v=... and {{APP_VER}} ---
|
||||||
|
# (?v=...) -> ?v=<QVER>
|
||||||
|
HTML_CSS_COUNT=0
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
sed -E -i "s/(\?v=)[^\"'&<>\s]*/\1${QVER}/g" "$f"
|
||||||
|
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
|
||||||
|
HTML_CSS_COUNT=$((HTML_CSS_COUNT+1))
|
||||||
|
done < <(find public -type f \( -name '*.html' -o -name '*.php' -o -name '*.css' \) -print0)
|
||||||
|
|
||||||
|
# --- JS: stamp placeholders and normalize any pre-existing ?v=... ---
|
||||||
|
JS_COUNT=0
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
# Replace placeholders
|
||||||
|
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
|
||||||
|
sed -E -i "s/\{\{APP_QVER\}\}/${QVER}/g" "$f"
|
||||||
|
# Normalize any "?v=..." that appear in ESM imports or strings
|
||||||
|
# This keeps any ".js" or ".mjs" then forces ?v=<QVER>
|
||||||
|
perl -0777 -i -pe "s@(\.m?js)\?v=[^\"')]+@\1?v=${QVER}@g" "$f"
|
||||||
|
JS_COUNT=$((JS_COUNT+1))
|
||||||
|
done < <(find public -type f -name '*.js' -print0)
|
||||||
|
|
||||||
|
# Force-write version.js (source of truth in stamped output)
|
||||||
|
if [[ -f public/js/version.js ]]; then
|
||||||
|
printf "window.APP_VERSION = '%s';\n" "$VER" > public/js/version.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Touched files: HTML/CSS/PHP=${HTML_CSS_COUNT}, JS=${JS_COUNT}"
|
||||||
|
|
||||||
|
# Final self-check: fail if anything is left
|
||||||
|
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" public \
|
||||||
|
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||||
|
echo "ERROR: Placeholders remain after stamping." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Stamped to ${VER} (${QVER})"
|
||||||
@@ -99,7 +99,7 @@ class AdminController
|
|||||||
'header_title' => '',
|
'header_title' => '',
|
||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => true,
|
||||||
'disableOIDCLogin' => true,
|
'disableOIDCLogin' => true,
|
||||||
'authBypass' => false,
|
'authBypass' => false,
|
||||||
'authHeaderName' => 'X-Remote-User'
|
'authHeaderName' => 'X-Remote-User'
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ public function deleteFiles()
|
|||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
// Need granular rename (or ancestor-owner)
|
// Need granular rename (or ancestor-owner)
|
||||||
if (!(ACL::canRename($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
if (!(ACL::canRename($username, $userPermissions, $folder))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -695,4 +695,79 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
|
|||||||
echo json_encode(['success' => false, 'error' => 'Not found']);
|
echo json_encode(['success' => false, 'error' => 'Not found']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/* -------------------- API: Move Folder -------------------- */
|
||||||
|
public function moveFolder(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
self::requireAuth();
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); echo json_encode(['error'=>'Method not allowed']); return; }
|
||||||
|
// CSRF: accept header or form field
|
||||||
|
$hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
$tok = $_SESSION['csrf_token'] ?? '';
|
||||||
|
if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) { http_response_code(403); echo json_encode(['error'=>'Invalid CSRF token']); return; }
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$input = json_decode($raw ?: "{}", true);
|
||||||
|
$source = trim((string)($input['source'] ?? ''));
|
||||||
|
$destination = trim((string)($input['destination'] ?? ''));
|
||||||
|
|
||||||
|
if ($source === '' || strcasecmp($source,'root')===0) { http_response_code(400); echo json_encode(['error'=>'Invalid source folder']); return; }
|
||||||
|
if ($destination === '') $destination = 'root';
|
||||||
|
|
||||||
|
// basic segment validation
|
||||||
|
foreach ([$source,$destination] as $f) {
|
||||||
|
if ($f==='root') continue;
|
||||||
|
$parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p)=>$p!=='');
|
||||||
|
foreach ($parts as $seg) {
|
||||||
|
if (!preg_match(REGEX_FOLDER_NAME, $seg)) { http_response_code(400); echo json_encode(['error'=>'Invalid folder segment']); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$srcNorm = trim($source, "/\\ ");
|
||||||
|
$dstNorm = $destination==='root' ? '' : trim($destination, "/\\ ");
|
||||||
|
|
||||||
|
// prevent move into self/descendant
|
||||||
|
if ($dstNorm !== '' && (strcasecmp($dstNorm,$srcNorm)===0 || strpos($dstNorm.'/', $srcNorm.'/')===0)) {
|
||||||
|
http_response_code(400); echo json_encode(['error'=>'Destination cannot be the source or its descendant']); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$perms = $this->loadPerms($username);
|
||||||
|
|
||||||
|
// enforce scopes (source manage-ish, dest write-ish)
|
||||||
|
if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; }
|
||||||
|
if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; }
|
||||||
|
|
||||||
|
// Check capabilities using ACL helpers
|
||||||
|
$canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source);
|
||||||
|
$canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination==='root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination));
|
||||||
|
if (!$canManageSource) { http_response_code(403); echo json_encode(['error'=>'Forbidden: manage rights required on source']); return; }
|
||||||
|
if (!$canMoveIntoDest) { http_response_code(403); echo json_encode(['error'=>'Forbidden: move rights required on destination']); return; }
|
||||||
|
|
||||||
|
// Non-admin: enforce same owner between source and destination tree (if any)
|
||||||
|
$isAdmin = self::isAdmin($perms);
|
||||||
|
if (!$isAdmin) {
|
||||||
|
try {
|
||||||
|
$ownerSrc = FolderModel::getOwnerFor($source) ?? '';
|
||||||
|
$ownerDst = $destination==='root' ? '' : (FolderModel::getOwnerFor($destination) ?? '');
|
||||||
|
if ($ownerSrc !== $ownerDst) {
|
||||||
|
http_response_code(403); echo json_encode(['error'=>'Source and destination must have the same owner']); return;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) { /* ignore – fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute final target "destination/basename(source)"
|
||||||
|
$baseName = basename(str_replace('\\','/', $srcNorm));
|
||||||
|
$target = $destination==='root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = FolderModel::renameFolder($source, $target);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('moveFolder error: '.$e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error'=>'Internal error moving folder']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserController
|
* UserController
|
||||||
@@ -665,4 +666,38 @@ class UserController
|
|||||||
echo json_encode(['success' => true, 'url' => $url]);
|
echo json_encode(['success' => true, 'url' => $url]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function siteConfig(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$usersDir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
$publicPath = $usersDir . 'siteConfig.json';
|
||||||
|
$adminEncPath = $usersDir . 'adminConfig.json';
|
||||||
|
|
||||||
|
$publicMtime = is_file($publicPath) ? (int)@filemtime($publicPath) : 0;
|
||||||
|
$adminMtime = is_file($adminEncPath) ? (int)@filemtime($adminEncPath) : 0;
|
||||||
|
|
||||||
|
// If public cache is present and fresh enough, serve it
|
||||||
|
if ($publicMtime > 0 && $publicMtime >= $adminMtime) {
|
||||||
|
$raw = @file_get_contents($publicPath);
|
||||||
|
$data = is_string($raw) ? json_decode($raw, true) : null;
|
||||||
|
if (is_array($data)) {
|
||||||
|
echo json_encode($data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise regenerate from decrypted admin config
|
||||||
|
$cfg = AdminModel::getConfig();
|
||||||
|
if (isset($cfg['error'])) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $cfg['error']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$public = AdminModel::buildPublicSubset($cfg);
|
||||||
|
$w = AdminModel::writeSiteConfig($public); // best effort
|
||||||
|
echo json_encode($public);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ class ACL
|
|||||||
unset($rec);
|
unset($rec);
|
||||||
return $changed ? self::save($acl) : true;
|
return $changed ? self::save($acl) : true;
|
||||||
}
|
}
|
||||||
|
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||||
|
{
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||||
|
|
||||||
|
$folder = trim($folder, "/\\ ");
|
||||||
|
if ($folder === '' || $folder === 'root') return false;
|
||||||
|
|
||||||
|
$parts = explode('/', $folder);
|
||||||
|
while (count($parts) > 1) {
|
||||||
|
array_pop($parts);
|
||||||
|
$parent = implode('/', $parts);
|
||||||
|
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||||
|
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||||
|
{
|
||||||
|
$old = self::normalizeFolder($oldFolder);
|
||||||
|
$new = self::normalizeFolder($newFolder);
|
||||||
|
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||||
|
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||||
|
|
||||||
|
$rebased = [];
|
||||||
|
foreach ($acl['folders'] as $k => $rec) {
|
||||||
|
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||||
|
$suffix = substr($k, strlen($old));
|
||||||
|
$suffix = ltrim((string)$suffix, '/');
|
||||||
|
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||||
|
$rebased[$newKey] = $rec;
|
||||||
|
} else {
|
||||||
|
$rebased[$k] = $rec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$acl['folders'] = $rebased;
|
||||||
|
self::save($acl);
|
||||||
|
}
|
||||||
|
|
||||||
private static function loadFresh(): array {
|
private static function loadFresh(): array {
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
@@ -323,10 +365,10 @@ class ACL
|
|||||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||||
|
|
||||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $mv = $dl = $ex = $sf = $sfo = true; }
|
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
||||||
if ($u && !$v && !$vo) $vo = true;
|
if ($u && !$v && !$vo) $vo = true;
|
||||||
//if ($s && !$v) $v = true;
|
//if ($s && !$v) $v = true;
|
||||||
if ($w) { $c = $u = $ed = $rn = $cp = $mv = $dl = $ex = true; }
|
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
||||||
|
|
||||||
if ($m) $rec['owners'][] = $user;
|
if ($m) $rec['owners'][] = $user;
|
||||||
if ($v) $rec['read'][] = $user;
|
if ($v) $rec['read'][] = $user;
|
||||||
@@ -419,9 +461,13 @@ public static function canCopy(string $user, array $perms, string $folder): bool
|
|||||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|| self::hasGrant($user, $folder, 'move')
|
}
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|
||||||
|
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||||
|
|||||||
@@ -62,6 +62,51 @@ class AdminModel
|
|||||||
return (int)$val;
|
return (int)$val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function buildPublicSubset(array $config): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'header_title' => $config['header_title'] ?? 'FileRise',
|
||||||
|
'loginOptions' => [
|
||||||
|
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||||
|
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||||
|
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||||
|
// do NOT include authBypass/authHeaderName here — admin-only
|
||||||
|
],
|
||||||
|
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||||
|
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||||
|
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||||
|
'oidc' => [
|
||||||
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
|
// never include clientId / clientSecret
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
||||||
|
public static function writeSiteConfig(array $publicSubset): array
|
||||||
|
{
|
||||||
|
$dest = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'siteConfig.json';
|
||||||
|
$tmp = $dest . '.tmp';
|
||||||
|
|
||||||
|
$json = json_encode($publicSubset, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
return ["error" => "Failed to encode siteConfig.json"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||||
|
return ["error" => "Failed to write temp siteConfig.json"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@rename($tmp, $dest)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
return ["error" => "Failed to move siteConfig.json into place"];
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($dest, 0664); // readable in bind mounts
|
||||||
|
return ["success" => true];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the admin configuration file.
|
* Updates the admin configuration file.
|
||||||
*
|
*
|
||||||
@@ -157,6 +202,14 @@ class AdminModel
|
|||||||
// Best-effort normalize perms for host visibility (user rw, group rw)
|
// Best-effort normalize perms for host visibility (user rw, group rw)
|
||||||
@chmod($configFile, 0664);
|
@chmod($configFile, 0664);
|
||||||
|
|
||||||
|
$public = self::buildPublicSubset($configUpdate);
|
||||||
|
$w = self::writeSiteConfig($public);
|
||||||
|
// Don’t fail the whole update if public cache write had a minor issue.
|
||||||
|
if (isset($w['error'])) {
|
||||||
|
// Log but keep success for admin write
|
||||||
|
error_log("AdminModel::writeSiteConfig warning: " . $w['error']);
|
||||||
|
}
|
||||||
|
|
||||||
return ["success" => "Configuration updated successfully."];
|
return ["success" => "Configuration updated successfully."];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +315,7 @@ class AdminModel
|
|||||||
],
|
],
|
||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => true,
|
||||||
'disableOIDCLogin' => true
|
'disableOIDCLogin' => true
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => "",
|
'globalOtpauthUrl' => "",
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ class FolderModel
|
|||||||
|
|
||||||
// Update ownership mapping for the entire subtree.
|
// Update ownership mapping for the entire subtree.
|
||||||
self::renameOwnersForTree($oldRel, $newRel);
|
self::renameOwnersForTree($oldRel, $newRel);
|
||||||
|
// Re-key explicit ACLs for the moved subtree
|
||||||
|
ACL::renameTree($oldRel, $newRel);
|
||||||
|
|
||||||
return ["success" => true];
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class UploadModel {
|
class UploadModel {
|
||||||
|
|
||||||
|
private static function sanitizeFolder(string $folder): string {
|
||||||
|
$folder = trim($folder);
|
||||||
|
if ($folder === '' || strtolower($folder) === 'root') return '';
|
||||||
|
// no traversal
|
||||||
|
if (strpos($folder, '..') !== false) return '';
|
||||||
|
// only safe chars + forward slashes
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_\-\/]+$/', $folder)) return '';
|
||||||
|
// normalize: strip leading slashes
|
||||||
|
return ltrim($folder, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles file uploads – supports both chunked uploads and full (non-chunked) uploads.
|
* Handles file uploads – supports both chunked uploads and full (non-chunked) uploads.
|
||||||
*
|
*
|
||||||
@@ -38,15 +51,19 @@ class UploadModel {
|
|||||||
return ["error" => "Invalid file name: $resumableFilename"];
|
return ["error" => "Invalid file name: $resumableFilename"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
|
$folderRaw = $post['folder'] ?? 'root';
|
||||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
||||||
return ["error" => "Invalid folder name"];
|
|
||||||
}
|
|
||||||
|
if (empty($files['file']) || !isset($files['file']['name'])) {
|
||||||
|
return ["error" => "No files received"];
|
||||||
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folder !== 'root') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
}
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ["error" => "Failed to create upload directory"];
|
||||||
}
|
}
|
||||||
@@ -56,12 +73,14 @@ class UploadModel {
|
|||||||
return ["error" => "Failed to create temporary chunk directory"];
|
return ["error" => "Failed to create temporary chunk directory"];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($files["file"]) || $files["file"]["error"] !== UPLOAD_ERR_OK) {
|
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||||
|
if ($chunkErr !== UPLOAD_ERR_OK) {
|
||||||
return ["error" => "Upload error on chunk $chunkNumber"];
|
return ["error" => "Upload error on chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$chunkFile = $tempDir . $chunkNumber;
|
$chunkFile = $tempDir . $chunkNumber;
|
||||||
if (!move_uploaded_file($files["file"]["tmp_name"], $chunkFile)) {
|
$tmpName = $files['file']['tmp_name'] ?? null;
|
||||||
|
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
|
||||||
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +119,7 @@ class UploadModel {
|
|||||||
fclose($out);
|
fclose($out);
|
||||||
|
|
||||||
// Update metadata.
|
// Update metadata.
|
||||||
$relativeFolder = $folder;
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
@@ -134,16 +152,16 @@ class UploadModel {
|
|||||||
|
|
||||||
return ["success" => "File uploaded successfully"];
|
return ["success" => "File uploaded successfully"];
|
||||||
} else {
|
} else {
|
||||||
// Handle full upload (non-chunked).
|
// Handle full upload (non-chunked)
|
||||||
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
|
$folderRaw = $post['folder'] ?? 'root';
|
||||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
||||||
return ["error" => "Invalid folder name"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folder !== 'root') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
}
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ["error" => "Failed to create upload directory"];
|
||||||
}
|
}
|
||||||
@@ -153,6 +171,10 @@ class UploadModel {
|
|||||||
$metadataChanged = [];
|
$metadataChanged = [];
|
||||||
|
|
||||||
foreach ($files["file"]["name"] as $index => $fileName) {
|
foreach ($files["file"]["name"] as $index => $fileName) {
|
||||||
|
// Basic PHP upload error check per file
|
||||||
|
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
||||||
|
return ["error" => "Error uploading file"];
|
||||||
|
}
|
||||||
$safeFileName = trim(urldecode(basename($fileName)));
|
$safeFileName = trim(urldecode(basename($fileName)));
|
||||||
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
||||||
return ["error" => "Invalid file name: " . $fileName];
|
return ["error" => "Invalid file name: " . $fileName];
|
||||||
@@ -161,21 +183,22 @@ class UploadModel {
|
|||||||
if (isset($post['relativePath'])) {
|
if (isset($post['relativePath'])) {
|
||||||
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
|
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
|
||||||
}
|
}
|
||||||
$uploadDir = $baseUploadDir;
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
if (!empty($relativePath)) {
|
if (!empty($relativePath)) {
|
||||||
$subDir = dirname($relativePath);
|
$subDir = dirname($relativePath);
|
||||||
if ($subDir !== '.' && $subDir !== '') {
|
if ($subDir !== '.' && $subDir !== '') {
|
||||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
// IMPORTANT: build the subfolder under the *current* base folder
|
||||||
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR .
|
||||||
|
str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
$safeFileName = basename($relativePath);
|
$safeFileName = basename($relativePath);
|
||||||
}
|
}
|
||||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) {
|
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create subfolder"];
|
return ["error" => "Failed to create subfolder: " . $uploadDir];
|
||||||
}
|
}
|
||||||
$targetPath = $uploadDir . $safeFileName;
|
$targetPath = $uploadDir . $safeFileName;
|
||||||
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
||||||
$folderPath = $folder;
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
if (!isset($metadataCollection[$metadataKey])) {
|
if (!isset($metadataCollection[$metadataKey])) {
|
||||||
@@ -208,7 +231,7 @@ class UploadModel {
|
|||||||
}
|
}
|
||||||
return ["success" => "Files uploaded successfully"];
|
return ["success" => "Files uploaded successfully"];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively removes a directory and its contents.
|
* Recursively removes a directory and its contents.
|
||||||
|
|||||||