Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 | ||
|
|
a9fe342175 | ||
|
|
7669f5a10b | ||
|
|
34a4e06a23 | ||
|
|
d00faf5fe7 | ||
|
|
ad8cbc601a | ||
|
|
40e000b5bc | ||
|
|
eee25a4dc6 | ||
|
|
d66f4d93cb | ||
|
|
f4f7f8ef38 | ||
|
|
0ccba45c40 | ||
|
|
620c916eb3 | ||
|
|
f809cc09d2 | ||
|
|
6758b5f73d | ||
|
|
30a0aaf05e | ||
|
|
c843f00738 | ||
|
|
4bb9d81370 | ||
|
|
29e0497730 | ||
|
|
dd3a7a5145 | ||
|
|
d00db803c3 | ||
|
|
77a94ecd85 | ||
|
|
699873848e | ||
|
|
9cb12c11a6 | ||
|
|
c08876380b | ||
|
|
5b824888cb | ||
|
|
b7d7f7c3ce | ||
|
|
e509b7ac9c | ||
|
|
947255d94c | ||
|
|
55d44ef880 | ||
|
|
ad76e37ad5 | ||
|
|
d664a2f5d8 | ||
|
|
a18a8df7af | ||
|
|
8cf5a34ae9 | ||
|
|
55d5656139 | ||
|
|
04be05ad1e | ||
|
|
0469d183de | ||
|
|
b1de8679e0 | ||
|
|
f4f7ec0dca | ||
|
|
5a7c4704d0 | ||
|
|
8b880738d6 | ||
|
|
06c732971f | ||
|
|
ab75381acb | ||
|
|
b1bd903072 | ||
|
|
ab327acc8a | ||
|
|
2e98ceee4c | ||
|
|
3351a11927 | ||
|
|
4dddcf0f99 | ||
|
|
35966964e7 | ||
|
|
7fe8e858ae | ||
|
|
64332211c9 | ||
|
|
3e37738e3f | ||
|
|
2ba33f40f8 | ||
|
|
badcf5c02b | ||
|
|
89976f444f | ||
|
|
9c53c37f38 | ||
|
|
a400163dfb | ||
|
|
ebe5939bf5 | ||
|
|
83757c7470 | ||
|
|
8e363ea758 | ||
|
|
2739925f0b | ||
|
|
b5610cf156 | ||
|
|
ae932a9aa9 | ||
|
|
a106d47f77 | ||
|
|
41d464a4b3 | ||
|
|
9e69f19e23 | ||
|
|
1df7bc3f87 | ||
|
|
e5f9831d73 | ||
|
|
553bc84404 | ||
|
|
88a8857a6f | ||
|
|
edefaaca36 | ||
|
|
ef0a8da696 | ||
|
|
ebabb561d6 | ||
|
|
30761b6dad | ||
|
|
9ef40da5aa | ||
|
|
371a763fb4 | ||
|
|
ee717af750 | ||
|
|
0ad7034a7d |
44
.gitattributes
vendored
@@ -1,4 +1,40 @@
|
||||
public/api.html linguist-documentation
|
||||
public/openapi.json linguist-documentation
|
||||
resources/ export-ignore
|
||||
.github/ export-ignore
|
||||
# --- Docs that shouldn't count toward code stats
|
||||
public/api.php linguist-documentation
|
||||
public/openapi.json linguist-documentation
|
||||
openapi.json.dist linguist-documentation
|
||||
SECURITY.md linguist-documentation
|
||||
CHANGELOG.md linguist-documentation
|
||||
CONTRIBUTING.md linguist-documentation
|
||||
CODE_OF_CONDUCT.md linguist-documentation
|
||||
LICENSE linguist-documentation
|
||||
README.md linguist-documentation
|
||||
|
||||
# --- Vendored/minified stuff: exclude from Linguist
|
||||
public/vendor/** linguist-vendored
|
||||
public/css/vendor/** linguist-vendored
|
||||
public/fonts/** linguist-vendored
|
||||
public/js/**/*.min.js linguist-vendored
|
||||
public/**/*.min.css linguist-vendored
|
||||
public/**/*.map linguist-generated
|
||||
|
||||
# --- Treat assets as binary (nicer diffs)
|
||||
*.png -diff
|
||||
*.jpg -diff
|
||||
*.jpeg -diff
|
||||
*.gif -diff
|
||||
*.webp -diff
|
||||
*.svg -diff
|
||||
*.ico -diff
|
||||
*.woff -diff
|
||||
*.woff2 -diff
|
||||
*.ttf -diff
|
||||
*.otf -diff
|
||||
*.zip -diff
|
||||
|
||||
# --- Keep these out of auto-generated source archives (OK to ignore)
|
||||
# Only ignore things you *never* need in release tarballs
|
||||
.github/ export-ignore
|
||||
resources/ export-ignore
|
||||
|
||||
# --- Normalize text files
|
||||
* text=auto
|
||||
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
github: [error311]
|
||||
ko_fi: error311
|
||||
312
.github/workflows/release-on-version.yml
vendored
Normal file
@@ -0,0 +1,312 @@
|
||||
---
|
||||
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]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
||||
required: false
|
||||
version:
|
||||
description: "Explicit version tag to release (e.g., v1.8.6). If empty, auto-detect."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
delay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delay 10 minutes
|
||||
run: sleep 600
|
||||
|
||||
release:
|
||||
needs: delay
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
|
||||
# Use run_id for a stable, unique key
|
||||
concurrency:
|
||||
group: release-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout (fetch all)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure tags + master available
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch --tags --force --prune --quiet
|
||||
git fetch origin master --quiet
|
||||
|
||||
- name: Resolve source ref + (maybe) version
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
REF=""
|
||||
VER=""
|
||||
SRC=""
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# manual run
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
VER_IN="${{ github.event.inputs.version }}"
|
||||
if [[ -n "$REF_IN" ]]; then
|
||||
# Try branch/sha; fetch branch if needed
|
||||
git fetch origin "$REF_IN" --quiet || true
|
||||
if REF_SHA="$(git rev-parse --verify --quiet "$REF_IN")"; then
|
||||
REF="$REF_SHA"
|
||||
else
|
||||
echo "Provided ref '$REF_IN' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF="$(git rev-parse origin/master)"
|
||||
fi
|
||||
if [[ -n "$VER_IN" ]]; then
|
||||
VER="$VER_IN"
|
||||
SRC="manual-version"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
REF="${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
|
||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
||||
if [[ -z "$VER" ]]; then
|
||||
# Search recent history reachable from REF
|
||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
||||
if [[ -n "$BOT_SHA" ]]; then
|
||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
||||
if [[ -n "$BOT_VER" ]]; then
|
||||
VER="$BOT_VER"
|
||||
REF="$BOT_SHA" # build/tag from the bump commit
|
||||
SRC="bot-commit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output
|
||||
REF_SHA="$(git rev-parse "$REF")"
|
||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
||||
|
||||
- name: Checkout chosen ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
|
||||
- name: Assert ref is on master
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REF="${{ steps.pickref.outputs.ref }}"
|
||||
git fetch origin master --quiet
|
||||
if ! git merge-base --is-ancestor "$REF" origin/master; then
|
||||
echo "Ref $REF is not on master; refusing to release."
|
||||
exit 78
|
||||
fi
|
||||
|
||||
- name: Debug version.js provenance
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version.js last-change commit: $(git log -n1 --pretty='%h %s' -- public/js/version.js || echo 'none')"
|
||||
sed -n '1,20p' public/js/version.js || true
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Prefer pre-resolved version (manual input or bot commit)
|
||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
||||
VER="${{ steps.pickref.outputs.preversion }}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (pre-resolved): $VER"
|
||||
exit 0
|
||||
fi
|
||||
# Fallback to version.js
|
||||
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 (file): $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
|
||||
|
||||
- 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 }}"
|
||||
rm -rf staging
|
||||
rsync -a \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'resources' \
|
||||
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
|
||||
./ staging/
|
||||
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 }}"
|
||||
(cd staging && zip -r "../FileRise-${VER}.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
|
||||
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: ${{ steps.pickref.outputs.ref }}
|
||||
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
|
||||
89
.github/workflows/sync-changelog.yml
vendored
@@ -1,44 +1,115 @@
|
||||
---
|
||||
name: Sync Changelog to Docker Repo
|
||||
name: Bump version and sync Changelog to Docker Repo
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'CHANGELOG.md'
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: bump-and-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
bump_and_sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout FileRise
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: file-rise
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Extract version from commit message
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSG="${{ github.event.head_commit.message }}"
|
||||
if [[ "$MSG" =~ release\((v[0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
|
||||
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
|
||||
echo "Found version: ${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "version=" >> "$GITHUB_OUTPUT"
|
||||
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
|
||||
fi
|
||||
|
||||
# Ensure we're on the branch and up to date BEFORE modifying files
|
||||
- name: Ensure clean branch (no local mods), update from remote
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Be on a named branch that tracks the remote
|
||||
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
|
||||
# Make sure the worktree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "::error::Working tree not clean before update. Aborting."
|
||||
git status --porcelain
|
||||
exit 1
|
||||
fi
|
||||
# Update branch
|
||||
git pull --rebase origin "${{ github.ref_name }}"
|
||||
|
||||
- name: Update public/js/version.js (source of truth)
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > public/js/version.js <<'EOF'
|
||||
// generated by CI
|
||||
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
||||
EOF
|
||||
|
||||
- name: Commit version.js only
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add public/js/version.js
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
|
||||
git push origin "${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
- name: Checkout filerise-docker
|
||||
if: steps.ver.outputs.version != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: error311/filerise-docker
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
path: docker-repo
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy CHANGELOG.md
|
||||
- name: Copy CHANGELOG.md and write VERSION
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
|
||||
set -euo pipefail
|
||||
cp CHANGELOG.md docker-repo/CHANGELOG.md
|
||||
echo "${{ steps.ver.outputs.version }}" > docker-repo/VERSION
|
||||
|
||||
- name: Commit & push
|
||||
- name: Commit & push to docker repo
|
||||
if: steps.ver.outputs.version != ''
|
||||
working-directory: docker-repo
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add CHANGELOG.md
|
||||
git add CHANGELOG.md VERSION
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
||||
git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
817
CHANGELOG.md
@@ -1,5 +1,811 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/8/2025 (v1.8.12)
|
||||
|
||||
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||
|
||||
- auth (public/js/main.js)
|
||||
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||
- Minor cleanup (SW register catch spacing).
|
||||
|
||||
- drag & drop (public/js/dragAndDrop.js)
|
||||
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||
|
||||
- styles (public/css/styles.css)
|
||||
- Header drop zone fills remaining space and right-aligns its icons.
|
||||
|
||||
UX:
|
||||
|
||||
- OIDC button reliably appears when form/basic are disabled.
|
||||
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.11)
|
||||
|
||||
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||
|
||||
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.10)
|
||||
|
||||
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||
|
||||
UI/UX — Media modal
|
||||
|
||||
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||
|
||||
Video/Image controls
|
||||
|
||||
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||
|
||||
Progress badges (list & modal)
|
||||
|
||||
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||
|
||||
Drag & drop
|
||||
|
||||
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||
|
||||
Editor & ONLYOFFICE
|
||||
|
||||
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||
|
||||
Assets & polish
|
||||
|
||||
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||
|
||||
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.9)
|
||||
|
||||
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||
|
||||
- adminPanel.js:
|
||||
- Masked inputs without a saved value now start with data-replace="1".
|
||||
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.8)
|
||||
|
||||
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||
|
||||
**Summary**
|
||||
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||
|
||||
**Backend** changes:
|
||||
|
||||
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot downloads.
|
||||
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
|
||||
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
|
||||
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
|
||||
|
||||
New **Worker**:
|
||||
|
||||
- New `src/cli/zip_worker.php` builds the archive in the background.
|
||||
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the per‑token JSON.
|
||||
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
|
||||
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
|
||||
|
||||
New **API/Status Payload**:
|
||||
|
||||
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
|
||||
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
|
||||
|
||||
**Frontend (UX)** changes:
|
||||
|
||||
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
|
||||
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
|
||||
- Shows determinate **0–98%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
|
||||
- Modal closes just before download; UI resets for the next operation.
|
||||
|
||||
Added **CSS**:
|
||||
|
||||
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
|
||||
|
||||
**Why this closes #60**?
|
||||
|
||||
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
|
||||
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
|
||||
- Users get continuous, truthful feedback for large multi‑GB archives.
|
||||
|
||||
Additional **Notes**
|
||||
|
||||
- Download tokens are **one‑shot** and are deleted after the GET completes.
|
||||
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/5/2025 (v1.8.7)
|
||||
|
||||
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
||||
|
||||
- FileController::downloadZip
|
||||
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
|
||||
- Close session locks, disable gzip/output buffering, set Content-Length when known
|
||||
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
|
||||
- Unlink the temp ZIP after successful send
|
||||
- Preserves all auth/ACL/ownership checks
|
||||
|
||||
- FileModel::createZipArchive
|
||||
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
|
||||
|
||||
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.6)
|
||||
|
||||
release(v1.8.6): fix large ZIP downloads + safer extract; close #60
|
||||
|
||||
- Zip creation
|
||||
- Write archives to META_DIR/ziptmp (on large/writable disk) instead of system tmp.
|
||||
- Auto-create ziptmp (0775) and verify writability.
|
||||
- Free-space sanity check (~files total +5% +20MB); clearer error on low space.
|
||||
- Normalize/validate folder segments; include only regular files.
|
||||
- set_time_limit(0); use CREATE|OVERWRITE; improved error handling.
|
||||
|
||||
- Zip extraction
|
||||
- New: stamp metadata for files in nested subfolders (per-folder metadata.json).
|
||||
- Skip hidden “dot” paths (files/dirs with any segment starting with “.”) by default
|
||||
via SKIP_DOTFILES_ON_EXTRACT=true; only extract allow-listed entries.
|
||||
- Hardenings: zip-slip guard, reject symlinks (external_attributes), zip-bomb limits
|
||||
(MAX_UNZIP_BYTES default 200GiB, MAX_UNZIP_FILES default 20k).
|
||||
- Persist metadata for all touched folders; keep extractedFiles list for top-level names.
|
||||
|
||||
Ops note: ensure /var/www/metadata/ziptmp exists & is writable (or mount META_DIR to a large volume).
|
||||
|
||||
Closes #60.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.5)
|
||||
|
||||
release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing `needs: delay`, final test
|
||||
|
||||
- No change release just testing
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.4)
|
||||
|
||||
release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races
|
||||
|
||||
- No change release just testing
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.3)
|
||||
|
||||
release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust
|
||||
|
||||
- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish.
|
||||
- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.2)
|
||||
|
||||
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
|
||||
|
||||
- **Highlights**
|
||||
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
|
||||
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
|
||||
|
||||
- **Details**
|
||||
- API (new):
|
||||
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
|
||||
- GET /api/media/getProgress.php — fetch per-file progress.
|
||||
- GET /api/media/getViewedMap.php — folder map for badges.
|
||||
|
||||
- **Frontend (media):**
|
||||
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
|
||||
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
|
||||
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
|
||||
|
||||
- **Mobile & PWA:**
|
||||
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
|
||||
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
|
||||
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
|
||||
|
||||
- **Notes**
|
||||
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
|
||||
- No breaking changes expected; endpoints are additive.
|
||||
|
||||
Closes #37.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/3/2025 (V1.8.1)
|
||||
|
||||
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
|
||||
|
||||
- Add ONLYOFFICE URL sanitizers:
|
||||
- getTrustedDocsOrigin(): enforce http/https, strip creds, normalize to origin
|
||||
- buildOnlyOfficeApiUrl(): construct fixed /web-apps/.../api.js via URL()
|
||||
- Probe hardening (addresses CodeQL js/xss-through-dom):
|
||||
- ooProbeScript/ooProbeFrame now use sanitized origins and fixed paths
|
||||
- optional CSP nonce support for injected script
|
||||
- optional iframe sandbox; robust cleanup/timeout handling
|
||||
- CSP helper now renders lines based on validated origin (fallback to raw for visibility)
|
||||
- Admin UI UX: placeholder switched to HTTPS example (`https://docs.example.com`)
|
||||
- Comments added to justify safety to static analyzers
|
||||
|
||||
Files: public/js/adminPanel.js
|
||||
|
||||
Refs: #37
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/3/2025 (v1.8.0)
|
||||
|
||||
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
|
||||
|
||||
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
|
||||
|
||||
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
|
||||
|
||||
- **Backend / API**
|
||||
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
|
||||
- New endpoints:
|
||||
- GET /api/onlyoffice/status.php — reports availability + supported exts.
|
||||
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
|
||||
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
|
||||
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
|
||||
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
|
||||
|
||||
- **Admin config / UI**
|
||||
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
|
||||
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
|
||||
|
||||
- **Frontend integration**
|
||||
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
|
||||
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
|
||||
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
|
||||
|
||||
- **AdminModel / config**
|
||||
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
|
||||
- Optional constants in config.php to override and debug.
|
||||
|
||||
- **Security & UX notes**
|
||||
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
|
||||
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
|
||||
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
|
||||
|
||||
- **Docs/Styling**
|
||||
- Minor CSS touch-ups around hover states and modal layout.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/2/2025 (v1.7.5)
|
||||
|
||||
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
|
||||
release(v1.7.5): retrigger CI bump (no code changes)
|
||||
release(v1.7.5): retrigger CI bump ensure up to date
|
||||
|
||||
### Security/headers
|
||||
|
||||
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
|
||||
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
|
||||
|
||||
### Previews & editor
|
||||
|
||||
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
|
||||
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
|
||||
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
|
||||
|
||||
### Login, theming & UX polish
|
||||
|
||||
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
|
||||
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
|
||||
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
|
||||
|
||||
### HTML/CSS & perf
|
||||
|
||||
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
|
||||
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
|
||||
- Header logo marked `fetchpriority="high"` for faster first paint.
|
||||
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
|
||||
|
||||
### Manual Deploy script
|
||||
|
||||
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
|
||||
|
||||
### Notes
|
||||
|
||||
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/31/2025 (v1.7.4)
|
||||
|
||||
release(v1.7.4): login hint replace toast + fix unauth boot
|
||||
|
||||
main.js
|
||||
|
||||
- Added isDemoHost() and showLoginTip(message).
|
||||
- In the unauth branch, call showLoginTip('Please log in to continue').
|
||||
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/31/2025 (v1.7.3)
|
||||
|
||||
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
|
||||
|
||||
### 🎃 Highlights (advantages) 👻 🦇
|
||||
|
||||
- ⚡ Faster, cleaner boot: a lightweight **main.js** decides auth/setup before painting, avoids flicker, and wires modules exactly once.
|
||||
- ♻️ Fewer duplicate actions: **request coalescer** dedupes POST/PUT/PATCH/DELETE to /api/* .
|
||||
- ✅ Truthy UX: global **toast bridge** queues early toasts and normalizes misleading “not found/already exists” messages after success.
|
||||
- 🔐 Smoother auth: CSRF priming/rotation + **TOTP step-up detection** across JSON & redirect paths; “Welcome back, `user`” toast once per tab.
|
||||
- 🌓 Polished UI: **dark-mode persistence with system fallback**, live siteConfig title application, higher-z modals, drag auto-scroll.
|
||||
- 🚀 Faster first paint & interactions: defer CodeMirror/Fuse/Resumable, promote preloaded CSS, and coalesce duplicate requests → snappier UI.
|
||||
- 🧭 Admin polish: live header title preview, masked OIDC fields with **Replace** flow, and a **read-only Sponsors/Donations** section.
|
||||
- 🧱 Safer & cache-smarter: opinionated .htaccess (CSP/HSTS/MIME/compression) + `?v={{APP_QVER}}` for versioned immutable assets.
|
||||
|
||||
### Core bootstrap (main.js) overhaul
|
||||
|
||||
- Early **toast bridge** (queues until domUtils is ready); expose `window.__FR_TOAST_FILTER__` for centralized rewrites/suppression.
|
||||
- **Result guard + request coalescer** wrapping `fetch`:
|
||||
- Dedupes same-origin `/api/*` mutating requests for ~800ms using a stable key (method + path + normalized body).
|
||||
- Tracks “last OK” JSON (`success|status|result=ok`) to suppress false-negative error toasts after success.
|
||||
- **Boot orchestrator** with hard guards:
|
||||
- `__FR_FLAGS` (`booted`, `initialized`, `wired.*`, `bootPromise`, `entryStarted`) to prevent double init/leaks.
|
||||
- **No-flicker login**: resolve `checkAuth()` + `setup` before showing UI; show login only when truly unauthenticated.
|
||||
- **Heavy boot** for authed users: load i18n, `appCore.loadCsrfToken/initializeApp`, first file list, then light UI wiring.
|
||||
- **Auth flow**:
|
||||
- `primeCsrf()` + `<meta name="csrf-token">` management; persist token in localStorage.
|
||||
- **TOTP** detection via header (`X-TOTP-Required`) & JSON (`totp_required` / `TOTP_REQUIRED`); calls `openTOTPLoginModal()`.
|
||||
- **Welcome toast** once per tab via `sessionStorage.__fr_welcomed`.
|
||||
- **UI/UX niceties**:
|
||||
- `applySiteConfig()` updates header title & login method visibility on both login & authed screens.
|
||||
- Dark-mode persistence with system fallback, proper a11y labels/icons.
|
||||
- Create dropdown/menu wiring with capture-phase outside-click + ESC close; modal cancel safeties.
|
||||
- Lift modals above cards (z-index), **drag auto-scroll** near viewport edges.
|
||||
- Dispatch legacy `DOMContentLoaded`/`load` **once** (supports older inline handlers).
|
||||
- Username label refresh for existing `.user-name-label` without injecting new DOM.
|
||||
|
||||
### Performance & UX changes
|
||||
|
||||
- CSS/first paint:
|
||||
- Preload Bootstrap & app CSS; promote at DOMContentLoaded; keep inline CSS minimal.
|
||||
- Add `width/height/decoding/fetchpriority` to logo to reduce layout shift.
|
||||
- Search/editor/uploads:
|
||||
- **fileListView.js**: lazy-load Fuse with instant substring fallback; `warmUpSearch()` hook.
|
||||
- **fileEditor.js**: lazy-load CodeMirror core/theme/modes; start plain then upgrade; guard very large files gracefully.
|
||||
- **upload.js**: lazy-load Resumable; resilient init; background warm-up; smarter addFile/submit; clearer toasts.
|
||||
- Toast/UX:
|
||||
- Install early toast bridge; queue & normalize messages; neutral “Done.” when server returns misleading errors after success.
|
||||
|
||||
### Correctness: uploads, paths, ACLs
|
||||
|
||||
- **UploadController/UploadModel**: normalize folders via `ACL::normalizeFolder(rawurldecode())`; stricter segment checks; consistent base paths; safer metadata writes; proper chunk presence/merge & temp cleanup.
|
||||
|
||||
### Auth hardening & resilience
|
||||
|
||||
- **auth.js/main.js/appCore.js**: CSRF rotate/retry (JSON then x-www-form-urlencoded fallback); robust login handling; fewer misleading error toasts.
|
||||
- **AuthController**: OIDC username fallback to `email` or `sub` when `preferred_username` missing.
|
||||
|
||||
### Admin panel
|
||||
|
||||
- **adminPanel.js**:
|
||||
- Live header title preview (instant update without reload).
|
||||
- Masked OIDC client fields with **Replace** button; saved-value hints; only send secrets when replacing.
|
||||
- **New “Sponsor / Donations” section (read-only)**:
|
||||
- GitHub Sponsors → `https://github.com/sponsors/error311`
|
||||
- Ko-fi → `https://ko-fi.com/error311`
|
||||
- Includes **Copy** and **Open** buttons; values are fixed.
|
||||
- **AdminController**: boolean for `oidc.hasClientId/hasClientSecret` to drive masked inputs.
|
||||
|
||||
### Security & caching (.htaccess)
|
||||
|
||||
- Consolidated security headers (CSP, CORP, HSTS on HTTPS), MIME types, compression (Brotli/Deflate), TRACE disable.
|
||||
- Caching rules:
|
||||
- HTML/version.js: no-cache; unversioned JS/CSS: 1h; unversioned static: 7d; **versioned assets `?v=`: 1y `immutable`**.
|
||||
- **config.php**: remove duplicate runtime headers (now via Apache) to avoid proxy/CDN conflicts.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No schema changes.
|
||||
- Ensure Apache modules (`headers`, `rewrite`, `brotli`/`deflate`) are available for the new .htaccess rules (fallbacks included).
|
||||
- Versioned assets mean users shouldn’t need a hard refresh; `?v={{APP_QVER}}` busts caches automatically.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/29/2025 (v1.7.0 & v1.7.1 & v1.7.2)
|
||||
|
||||
release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- Public, non-sensitive site config cache:
|
||||
- Add `AdminModel::buildPublicSubset()` and `writeSiteConfig()` to write `USERS_DIR/siteConfig.json`.
|
||||
- New endpoint `public/api/siteConfig.php` + `UserController::siteConfig()` to serve the public subset (regenerates if stale).
|
||||
- Frontend now reads `/api/siteConfig.php` (safe subset) instead of `/api/admin/getConfig.php`.
|
||||
- Frontend module versioning:
|
||||
- Replace all module imports with `?v={{APP_QVER}}` query param so the release/Docker stamper can pin exact versions.
|
||||
- Add `scripts/stamp-assets.sh` to stamp `?v=` and `{{APP_VER}}/{{APP_QVER}}` in **staging** for ZIP/Docker builds.
|
||||
|
||||
### 🧩 Refactors
|
||||
|
||||
- Extract shared boot/bootstrap logic into `public/js/appCore.js`:
|
||||
- CSRF helpers (`setCsrfToken`, `getCsrfToken`, `loadCsrfToken`)
|
||||
- `initializeApp()`, `triggerLogout()`
|
||||
- Keep `main.js` lean; wrap global `fetch` once to append/rotate CSRF.
|
||||
- Update imports across JS modules to use versioned module URLs.
|
||||
|
||||
### 🚀 Performance
|
||||
|
||||
- Aggressive, safe caching for versioned assets:
|
||||
- `.htaccess`: `?v=…` ⇒ `Cache-Control: max-age=31536000, immutable`.
|
||||
- Unversioned JS/CSS short cache (1h), other static (7d).
|
||||
- Eliminate duplicate `main.js` loads and tighten CodeMirror mode loading.
|
||||
|
||||
### 🔒 Security / Hardening
|
||||
|
||||
- `.htaccess`:
|
||||
- Conditional HSTS only when HTTPS, add CORP and X-Permitted-Cross-Domain-Policies.
|
||||
- CSP kept strict for modules, workers, blobs.
|
||||
- Admin config exposure reduced to a curated subset in `siteConfig.json`.
|
||||
|
||||
### 🧪 CI/CD / Release
|
||||
|
||||
- **FileRise repo**
|
||||
- `sync-changelog.yml`: keep `public/js/version.js` as source-of-truth only (no repo-wide stamping).
|
||||
- `release-on-version.yml`: build **stamped** ZIP from a staging copy via `scripts/stamp-assets.sh`, verify placeholders removed, attach checksum.
|
||||
- **filerise-docker repo**
|
||||
- Read `VERSION`, checkout app to `app/`, run stamper inside build context before `docker buildx`, tag `latest` and `:${VERSION}`.
|
||||
|
||||
### 🔧 Defaults
|
||||
|
||||
- Sample/admin config defaults now set `disableBasicAuth: true` (safer default). Existing installations keep their current setting.
|
||||
|
||||
### 📂 Notable file changes
|
||||
|
||||
- `src/models/AdminModel.php` (+public subset +atomic write)
|
||||
- `src/controllers/UserController.php` (+siteConfig action)
|
||||
- `public/api/siteConfig.php` (new)
|
||||
- `public/js/appCore.js` (new), `public/js/main.js` (slim, uses appCore)
|
||||
- Many `public/js/*.js` import paths updated to `?v={{APP_QVER}}`
|
||||
- `public/.htaccess` (caching & headers)
|
||||
- `scripts/stamp-assets.sh` (new)
|
||||
|
||||
### ⚠️ Upgrade notes
|
||||
|
||||
- Ensure `USERS_DIR` is writable by web server for `siteConfig.json`.
|
||||
- Proxies/edge caches: the new `?v=` scheme enables long-lived immutable caching; purge is automatic on version bump.
|
||||
- If you previously read admin config directly on the client, it now reads `/api/siteConfig.php`.
|
||||
|
||||
### Additional changes/fixes for release
|
||||
|
||||
- `release-on-version.yml`
|
||||
- normalize line endings (strip CRLF)
|
||||
- stamp-assets.sh don’t rely on the exec; invoke via bash
|
||||
|
||||
release(v1.7.2): harden asset stamping & CI verification
|
||||
|
||||
### build(stamper)
|
||||
|
||||
- Rewrite scripts/stamp-assets.sh to be repo-agnostic and macOS/Windows friendly:
|
||||
- Drop reliance on git ls-files/mapfile; use find + null-delimited loops
|
||||
- Normalize CRLF to LF for all web assets before stamping
|
||||
- Stamp ?v=<APP_QVER> in HTML/CSS/PHP and {{APP_VER}} everywhere
|
||||
- Normalize any ".mjs|.js?v=..." occurrences inside JS (ESM imports/strings)
|
||||
- Force-write public/js/version.js from VER (source of truth in stamped output)
|
||||
- Print touched counts and fail fast if any {{APP_QVER}}|{{APP_VER}} remain
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/28/2025 (v1.6.11)
|
||||
|
||||
release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action
|
||||
|
||||
Re-add the click handler to toggle `zonesCollapsed` so the header
|
||||
“sidebarToggleFloating” button actually expands/collapses the zones
|
||||
again. This regressed in v1.6.10 during auth-gating refactor.
|
||||
|
||||
Refs: #regression #ux
|
||||
|
||||
chore(codeql): move config to repo root for default setup
|
||||
|
||||
- Relocate .github/codeql/codeql-config.yml to codeql-config.yml so GitHub default code scanning picks it up
|
||||
- Keep paths: public/js, api
|
||||
- Keep ignores: public/vendor/**, public/css/vendor/**, public/fonts/**, public/**/*.min.{js,css}, public/**/*.map
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/28/2025 (v1.6.10)
|
||||
|
||||
release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow
|
||||
|
||||
- Vendor ReDoc and add MIT license file under public/vendor/redoc/; switch api.php to local bundle to satisfy CSP (script-src 'self').
|
||||
- main.js: add/remove body.authenticated on login/logout so UI can reflect auth state.
|
||||
- dragAndDrop.js: only render sidebarToggleFloating when authenticated; stop event bubbling, keep dark-mode styles.
|
||||
- sync-changelog.yml: also stamp ?v= in PHP templates (public/**/*.php).
|
||||
- release-on-version.yml: build zip first, compute SHA-256, assemble release body with latest CHANGELOG snippet, “Full Changelog” compare link, and attach .sha256 alongside the zip.
|
||||
- THIRD_PARTY.md: document ReDoc vendoring and rationale.
|
||||
|
||||
Refs: #security #csp #release
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/27/2025 (v1.6.9)
|
||||
|
||||
release(v1.6.9): feat(core) localize assets, harden headers, and speed up load
|
||||
|
||||
- index.html: drop all CDNs in favor of local /vendor assets
|
||||
- add versioned cache-busting query (?v=…) on CSS/JS
|
||||
- wire version.js for APP_VERSION and numeric cache key
|
||||
- public/vendor/: add pinned copies of:
|
||||
- bootstrap 4.5.2, codemirror 5.65.5 (+ themes/modes), dompurify 2.4.0,
|
||||
fuse.js 6.6.2, resumable.js 1.1.0
|
||||
- fonts: add self-hosted Material Icons + Roboto (latin + latin-ext) with
|
||||
vendor CSS (material-icons.css, roboto.css)
|
||||
|
||||
- fileEditor.js: load CodeMirror modes from local vendor with ?v=APP_VERSION_NUM,
|
||||
keep timeout/plain-text fallback, no SRI (same-origin)
|
||||
- dragAndDrop.js: nudge zonesToggle 65px left to sit tighter to the logo
|
||||
|
||||
- styles.css: prune/organize rules and add small utility classes; move 3P
|
||||
font CSS to /css/vendor/
|
||||
|
||||
- .htaccess: security + performance overhaul
|
||||
- Content-Security-Policy: default-src 'self'; img-src include data: and blob:
|
||||
- version-aware caching: HTML/version.js = no-cache; assets with ?v= = 1y immutable
|
||||
- correct MIME for fonts/SVG; enable Brotli/Gzip (if available)
|
||||
- X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy
|
||||
- disable TRACE; deny dotfiles; prevent directory listing
|
||||
|
||||
- .gitattributes: mark vendor/minified as linguist-vendored, treat assets as
|
||||
binary in diffs, exclude CI/resources from source archives
|
||||
|
||||
- docs/licensing:
|
||||
- add licenses/ and THIRD_PARTY.md with upstream licenses/attribution
|
||||
- README: add “License & Credits” section with components and licenses
|
||||
|
||||
- CI: (sync-changelog) stamp asset cache-busters to the numeric release
|
||||
(e.g. ?v=1.6.9) and write window.APP_VERSION in version.js before Docker build
|
||||
|
||||
perf: site loads significantly faster with local assets + compression + long-lived caching
|
||||
security: CSP, strict headers, and same-origin assets reduce XSS/SRI/CORS risk
|
||||
|
||||
Refs: #performance #security
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/25/2025 (v1.6.8)
|
||||
|
||||
release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder
|
||||
|
||||
- Seed `currentFolder` from `localStorage.lastOpenedFolder` (fallback to "root")
|
||||
- Stop eager `loadFileList('root')` on boot; defer initial load to resolved folder
|
||||
- Hide capability-gated actions by default (`#extractZipBtn`, `#createBtn`) to avoid pre-auth flash
|
||||
- Eliminates transient root state when reloading inside a subfolder
|
||||
|
||||
User-visible: refreshing a non-root folder no longer flashes Root items or privileged buttons; app resumes in the last opened folder.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/25/2025 (v1.6.7)
|
||||
|
||||
release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish
|
||||
|
||||
### 📂 Folder Move (new major feature)
|
||||
|
||||
**Drag & Drop to move folder, use context menu or Move Folder button**
|
||||
|
||||
- Added **Move Folder** support across backend and UI.
|
||||
- New API endpoint: `public/api/folder/moveFolder.php`
|
||||
- Controller and ACL updates to validate scope, ownership, and permissions.
|
||||
- Non-admins can only move within folders they own.
|
||||
- `ACL::renameTree()` re-keys all subtree ACLs on folder rename/move.
|
||||
- Introduced new capabilities:
|
||||
- `canMoveFolder`
|
||||
- `canMove` (UI alias for backward compatibility)
|
||||
- New “Move Folder” button + modal in the UI with full i18n strings (`i18n.js`).
|
||||
- Action button styling and tooltip consistency for all folder actions.
|
||||
|
||||
### 🧱 Drag & Drop / Layout Improvements
|
||||
|
||||
- Fixed **random sidebar → top zone jumps** on refresh.
|
||||
- Cards/panels now **persist exactly where you placed them** (`userZonesSnapshot`)
|
||||
— no unwanted repositioning unless the window is resized below the small-screen threshold.
|
||||
- Added hysteresis around the 1205 px breakpoint to prevent flicker when resizing.
|
||||
- Eliminated the 50 px “ghost” gutter with `clampSidebarWhenEmpty()`:
|
||||
- Sidebar no longer reserves space when collapsed or empty.
|
||||
- Temporarily “unclamps” during drag so drop targets remain accurate and full-width.
|
||||
- Removed forced 800 px height on drag highlight; uses natural flex layout now.
|
||||
- General layout polish — smoother transitions when toggling *Hide/Show Panels*.
|
||||
|
||||
### ☁️ Uploads & UX
|
||||
|
||||
- Stronger folder sanitization and safer base-path handling.
|
||||
- Fixed subfolder creation when uploading directories (now builds under correct parent).
|
||||
- Improved chunk error handling and metadata key correctness.
|
||||
- Clearer success/failure toasts and accurate filename display from server responses.
|
||||
|
||||
### 🔐 Permissions / ACL
|
||||
|
||||
- Simplified file rename checks — now rely solely on granular `ACL::canRename()`.
|
||||
- Updated capability lists to include move/rename operations consistently.
|
||||
|
||||
### 🌐 UI / i18n Enhancements
|
||||
|
||||
- Added i18n strings for new “Move Folder” prompts, modals, and tooltips.
|
||||
- Minor UI consistency tweaks: button alignment, focus states, reduced-motion support.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/24/2025 (v1.6.6)
|
||||
|
||||
release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix
|
||||
|
||||
- dragAndDrop: mount zones toggle beside header logo (absolute, non-scrolling);
|
||||
stop click propagation so it doesn’t trigger the logo link; theme-aware styling
|
||||
- live updates via MutationObserver; snapshot card locations on drop and restore
|
||||
on load (prevents sidebar reset); guard first-run defaults with
|
||||
`layoutDefaultApplied_v1`; small/medium layout tweaks & refactors.
|
||||
- CSS: switch toggle icon to CSS variable (`--toggle-icon-color`) with dark-mode
|
||||
override; remove hardcoded `!important`.
|
||||
- API (capabilities.php): remove unused `disableUpload` flag from `canUpload`
|
||||
and flags payload to resolve undefined variable warning.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/24/2025 (v1.6.5)
|
||||
|
||||
release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php
|
||||
|
||||
- Fix undefined variable: use $disableUpload consistently
|
||||
- Harden flag read: (bool)($perms['disableUpload'] ?? false)
|
||||
- Prevents warning and ensures Upload capability is computed correctly
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/24/2025 (v1.6.4)
|
||||
|
||||
release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks
|
||||
|
||||
- Add public/js/version.js (default "dev") and load it before main.js.
|
||||
- adminPanel.js: replace hard-coded string with `window.APP_VERSION || "dev"`.
|
||||
- public/.htaccess: add no-cache for js/version.js
|
||||
- GitHub Actions: replace sync job with “Bump version and sync Changelog to Docker Repo”.
|
||||
- Parse commit msg `release(vX.Y.Z)` -> set step output `version`.
|
||||
- Write `public/js/version.js` with `window.APP_VERSION = '<version>'`.
|
||||
- Commit/push version.js if changed.
|
||||
- Mirror CHANGELOG.md to filerise-docker and write a VERSION file with `<version>`.
|
||||
- Guard all steps with `if: steps.ver.outputs.version != ''` to no-op on non-release commits.
|
||||
|
||||
This wires the UI version label to CI, keeps dev builds showing “dev”, and feeds the Docker repo with CHANGELOG + VERSION for builds.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/24/2025 (v1.6.3)
|
||||
|
||||
release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58)
|
||||
|
||||
Drag & Drop - Upload/Folder Management Cards layout
|
||||
|
||||
- Persist panel locations across refresh; snapshot + restore when collapsing/expanding.
|
||||
- Unified “zones” toggle; header-icon mode no longer loses card state.
|
||||
- Responsive: auto-move sidebar cards to top on small screens; restore on resize.
|
||||
- Better top-zone placeholder/cleanup during drag; tighter header modal sizing.
|
||||
- Safer order saving + deterministic placement for upload/folder cards.
|
||||
|
||||
Admin Panel – Folder Access
|
||||
|
||||
- Fix: newly created folders now appear without a full page refresh (cache-busted `getFolderList`).
|
||||
- Show admin users in the list with full access pre-applied and inputs disabled (read-only).
|
||||
- Skip sending updates for admins when saving grants.
|
||||
- “Folder” column now has its own horizontal scrollbar so long names / “Inherited from …” are never cut off.
|
||||
|
||||
Admin Panel – User Permissions (flags)
|
||||
|
||||
- Show admins (marked as Admin) with all switches disabled; exclude from save payload.
|
||||
- Clarified helper text (account-level vs per-folder).
|
||||
|
||||
UI/Styling
|
||||
|
||||
- Added `.folder-cell` scroller in ACL table; improved dark-mode scrollbar/thumb.
|
||||
|
||||
Docs
|
||||
|
||||
- README edits:
|
||||
- Clarified PUID/PGID mapping and host/NAS ownership requirements for mounted volumes.
|
||||
- Environment variables section added
|
||||
- CHOWN_ON_START additional details
|
||||
- Admin details
|
||||
- Upgrade section added
|
||||
- 💖 Sponsor FileRise section added
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/23/2025 (v1.6.2)
|
||||
|
||||
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
||||
|
||||
- Add zh-CN locale to i18n.js with full key set.
|
||||
- Introduce chinese_simplified label key across locales.
|
||||
- Added some missing labels
|
||||
- Update language selector mapping to include zh-CN (English/Spanish/French/German/简体中文).
|
||||
- Wire zh-CN into Auth/User Panel (authModals) language dropdown.
|
||||
- Fallback-safe rendering for language names when a key is missing.
|
||||
|
||||
ui: fix “Change Password” button sizing in User Panel
|
||||
|
||||
- Keep consistent padding and font size for cleaner layout
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/23/2025 (v1.6.1)
|
||||
|
||||
feat(ui): unified zone toggle + polished interactions for sidebar/top cards
|
||||
|
||||
- Add floating toggle button styling (hover lift, press, focus ring, ripple)
|
||||
for #zonesToggleFloating and #sidebarToggleFloating (CSS).
|
||||
- Ensure icons are visible and centered; enforce consistent sizing/color.
|
||||
- Introduce unified “zones collapsed” state persisted via `localStorage.zonesCollapsed`.
|
||||
- Update dragAndDrop.js to:
|
||||
- manage a single floating toggle for both Sidebar and Top Zone
|
||||
- keep toggle visible when cards are in Top Zone; hide only when both cards are in Header
|
||||
- rotate icon 90° when both cards are in Top Zone and panels are open
|
||||
- respect collapsed state during DnD flows and on load
|
||||
- preserve original DnD behaviors and saved orders (sidebar/header)
|
||||
- Minor layout/visibility fixes during drag (clear temp heights; honor collapsed).
|
||||
|
||||
Notes:
|
||||
|
||||
- No breaking API changes; existing `sidebarOrder` / `headerOrder` continue to work.
|
||||
- New key: `zonesCollapsed` (string '0'/'1') controls visibility of Sidebar + Top Zone.
|
||||
|
||||
UX:
|
||||
|
||||
- Floating toggle feels more “material”: subtle hover elevation, press feedback,
|
||||
focus ring, and click ripple to restore the prior interactive feel.
|
||||
- Icons remain legible on white (explicit color set), centered in the circular button.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/22/2025 (v1.6.0)
|
||||
|
||||
feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
|
||||
|
||||
- Add granular ACL buckets: create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
|
||||
- Implement ACL::canX helpers and expand upsert/explicit APIs (preserve read_own)
|
||||
- Enforce “write no longer implies read” in canRead; use granular gates for write-ish ops
|
||||
- WebDAV: use canDelete for DELETE, canUpload/canEdit + disableUpload for PUT; enforce ownership on overwrite
|
||||
- Folder create: require Manage/Owner on parent; normalize paths; seed ACL; rollback on failure
|
||||
- FileController: refactor copy/move/rename/delete/extract to granular gates + folder-scope checks + own-only ownership enforcement
|
||||
- Capabilities API: compute effective actions with scope + readOnly/disableUpload; protect root
|
||||
- Admin Panel (v1.6.0): new Folder Access editor with granular caps, inheritance hints, bulk toggles, and UX validations
|
||||
- getFileList: keep root visible but inert for users without visibility; apply own-only filtering server-side
|
||||
- Bump version to v1.6.0
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/20/2025 (v1.5.3)
|
||||
|
||||
security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
|
||||
@@ -36,6 +842,17 @@ feat(dnd): default cards to sidebar on medium screens when no saved layout
|
||||
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
|
||||
- Keeps user changes persistent; no override once a layout exists
|
||||
|
||||
feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads
|
||||
|
||||
- Build the editor modal immediately and wire close (✖, Close button, and Esc) before any async work, so the UI is always dismissible.
|
||||
- Restore MODE_URL and add normalizeModeName() to resolve aliases (text/html → htmlmixed, php → application/x-httpd-php).
|
||||
- Add SRI for each lazily loaded mode (MODE_SRI) and apply integrity/crossOrigin on script tags; switch to async and improved error messages.
|
||||
- Introduce MODE_LOAD_TIMEOUT_MS=2500 and Promise.race() to init in text/plain if a mode is slow; auto-upgrade to the real mode once it arrives.
|
||||
- Graceful fallback: if CodeMirror core isn’t present, keep textarea, enable Save, and proceed.
|
||||
- Minor UX: disable Save until the editor is ready, support theme toggling, better resize handling, and font size controls without blocking.
|
||||
|
||||
Security: Locks CDN mode scripts with SRI.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/19/2025 (v1.5.2)
|
||||
|
||||
195
README.md
@@ -7,52 +7,87 @@
|
||||
[](https://demo.filerise.net)
|
||||
[](https://github.com/error311/FileRise/releases)
|
||||
[](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) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting)
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek, responsive web interface.
|
||||
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
|
||||
|
||||
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
|
||||
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
|
||||
|
||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
||||
|
||||
New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
**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:**
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
|
||||
|
||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
|
||||
|
||||
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items/page); file sizes are displayed in MB. Share individual files with one-time or expiring links (optional password protection).
|
||||
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
|
||||
|
||||
- 🔐 **Fine-grained Access Control (ACL):** Per-folder grants for **owners**, **read** (view all), **read_own** (own-only visibility), **write** (upload/edit), and **share**.
|
||||
- _Note:_ **write no longer implies read**. Grant **read** if uploaders should see all files; or **read_own** for self-only listings.
|
||||
- Enforced server-side across UI, API, and WebDAV. Includes an admin UI for bulk editing (atomic updates) and safe defaults.
|
||||
- 🔐 **Granular Access Control (ACL):**
|
||||
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
||||
Each grant controls specific actions across the UI, API, and WebDAV:
|
||||
|
||||
- 🔌 **WebDAV Support (ACL-aware):** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV ops (upload / download / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can script with `curl`. Listings require **read**; users with **read_own** only see their own files; writes require **write**.
|
||||
| Permission | Description |
|
||||
|-------------|-------------|
|
||||
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
|
||||
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
|
||||
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
|
||||
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
|
||||
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
|
||||
| **Upload** | Allows uploading new files without granting full write privileges. |
|
||||
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
|
||||
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
|
||||
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
||||
|
||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor with syntax highlighting and line numbers.
|
||||
ACL enforcement is centralized and atomic across:
|
||||
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
|
||||
- **API Endpoints:** All file/folder operations validate server-side.
|
||||
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
|
||||
|
||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using indexed real-time search. **Advanced Search** adds fuzzy matching across file names, tags, uploader fields, and within text file contents.
|
||||
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
|
||||
- Listings require **View** or **View (Own)**.
|
||||
- Uploads require **Upload**.
|
||||
- Overwrites require **Edit**.
|
||||
- Deletes require **Delete**.
|
||||
- Creating folders requires **Create** or **Manage**.
|
||||
- All ACLs and ownership rules are enforced exactly as in the web UI.
|
||||
|
||||
- 🔒 **Auth & SSO:** Username/password login, optional TOTP 2FA, and OIDC (Google/Authentik/Keycloak). Per-user flags like **readOnly**/**disableUpload** still supported, but folder access is governed by the ACL above.
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
||||
|
||||
- 🗑️ **Trash & Recovery:** Deleted items go to Trash first; **admins** can restore or empty. Old trash entries auto-purge (default 3 days).
|
||||
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
|
||||
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
|
||||
|
||||
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
|
||||
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||
|
||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP **8.3+** with no external database. Single-folder install or Docker image. Low footprint; scales to thousands of files with pagination and sorting.
|
||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||
|
||||
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
|
||||
|
||||
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
|
||||
|
||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
|
||||
|
||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
||||
|
||||
@@ -73,6 +108,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)
|
||||
|
||||
#### Pull the image
|
||||
@@ -91,7 +142,7 @@ docker run -d \
|
||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e PUID="1000" \
|
||||
-e PGID="1000" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
@@ -103,6 +154,8 @@ docker run -d \
|
||||
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`.
|
||||
|
||||
**Notes**
|
||||
@@ -125,10 +178,10 @@ docker exec -it filerise id www-data
|
||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
@@ -136,7 +189,7 @@ services:
|
||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
# Ownership & indexing
|
||||
PUID: "1000" # Unraid users often use 99
|
||||
PGID: "1000" # Unraid users often use 100
|
||||
@@ -148,11 +201,14 @@ services:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Access at `http://localhost:8080` (or your server’s IP).
|
||||
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||
|
||||
- “`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
|
||||
|
||||
**First-time Setup**
|
||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||
|
||||
@@ -217,6 +273,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
|
||||
|
||||
- Install from **Community Apps** → search **FileRise**.
|
||||
@@ -226,6 +289,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
|
||||
|
||||
Once FileRise is running, enable WebDAV in the admin panel.
|
||||
@@ -272,8 +345,49 @@ https://your-host/webdav.php/
|
||||
|
||||
---
|
||||
|
||||
## Quick start: ONLYOFFICE (optional)
|
||||
|
||||
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
|
||||
|
||||
**What you need**
|
||||
|
||||
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
|
||||
- A shared **JWT secret** used by FileRise and your Document Server.
|
||||
|
||||
**Setup (2–3 minutes)**
|
||||
|
||||
1. In FileRise go to **Admin → ONLYOFFICE** and:
|
||||
- ✅ Enable ONLYOFFICE
|
||||
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
|
||||
- 🔑 Enter **JWT Secret** (click “Replace” to set)
|
||||
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
|
||||
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
|
||||
3. Update your **Content-Security-Policy** to allow the DS origin.
|
||||
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
|
||||
|
||||
**Apache**
|
||||
|
||||
```apache
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||
```
|
||||
|
||||
**Nginx**
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||
```
|
||||
|
||||
**Notes**
|
||||
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
|
||||
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
|
||||
|
||||
---
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
- **ONLYOFFICE editor won’t load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
|
||||
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
|
||||
|
||||
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||
|
||||
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||
@@ -306,6 +420,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
|
||||
|
||||
- **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).
|
||||
@@ -318,6 +443,20 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
||||
|
||||
## Dependencies
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
||||
|
||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
|
||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||
|
||||
#### Security / CSP
|
||||
|
||||
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
@@ -343,6 +482,10 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
## License & Credits
|
||||
|
||||
MIT License – see [LICENSE](LICENSE).
|
||||
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
|
||||
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
|
||||
|
||||
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
|
||||
|
||||
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
|
||||
@@ -1,22 +1,6 @@
|
||||
<?php
|
||||
// config.php
|
||||
|
||||
// Prevent caching
|
||||
header("Cache-Control: no-cache, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Expires: 0");
|
||||
|
||||
// Security headers
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header("X-Frame-Options: SAMEORIGIN");
|
||||
header("Referrer-Policy: no-referrer-when-downgrade");
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
|
||||
}
|
||||
|
||||
// Define constants
|
||||
define('PROJECT_ROOT', dirname(__DIR__));
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
@@ -40,6 +24,18 @@ if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||
define('ACL_INHERIT_ON_CREATE', true);
|
||||
// ONLYOFFICE integration overrides (uncomment and set as needed)
|
||||
/*
|
||||
define('ONLYOFFICE_ENABLED', false);
|
||||
define('ONLYOFFICE_JWT_SECRET', 'test123456');
|
||||
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||
}
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
|
||||
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.
|
||||
146
public/.htaccess
@@ -1,75 +1,121 @@
|
||||
# -----------------------------
|
||||
# 1) Prevent directory listings
|
||||
# -----------------------------
|
||||
Options -Indexes
|
||||
|
||||
# -----------------------------
|
||||
# Default index files
|
||||
# -----------------------------
|
||||
# --------------------------------
|
||||
# FileRise portable .htaccess
|
||||
# --------------------------------
|
||||
Options -Indexes -Multiviews
|
||||
DirectoryIndex index.html
|
||||
|
||||
# -----------------------------
|
||||
# Deny access to hidden files
|
||||
# -----------------------------
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# ---------------- Security: dotfiles ----------------
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
<FilesMatch "^\..*">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Enforce HTTPS (optional)
|
||||
# -----------------------------
|
||||
# ---------------- Rewrites ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
#RewriteCond %{HTTPS} off
|
||||
|
||||
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
RewriteRule - - [L]
|
||||
|
||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
|
||||
# 2) Deny direct access to PHP outside /api/
|
||||
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteRule \.php$ - [F]
|
||||
|
||||
# 3) Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# 4) HTTPS redirect (enable ONE of these, comment the other)
|
||||
|
||||
# A) Direct TLS on this server
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Allow requests from a specific origin
|
||||
#Header set Access-Control-Allow-Origin "https://demo.filerise.net"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
|
||||
Header set Access-Control-Allow-Credentials "true"
|
||||
# B) Behind reverse proxy that sets X-Forwarded-Proto
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
|
||||
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||
RewriteRule ^ - [E=IS_VER:1]
|
||||
</IfModule>
|
||||
|
||||
# ---------------- MIME types ----------------
|
||||
<IfModule mod_mime.c>
|
||||
AddType font/woff2 .woff2
|
||||
AddType font/woff .woff
|
||||
AddType image/svg+xml .svg
|
||||
AddType application/javascript .mjs
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Security headers ----------------
|
||||
<IfModule mod_headers.c>
|
||||
# Prevent clickjacking
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
# Block XSS
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
# No MIME sniffing
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
Header always set X-Download-Options "noopen"
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||
Header always set X-Permitted-Cross-Domain-Policies "none"
|
||||
|
||||
# HSTS only when HTTPS (safe for .htaccess)
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
|
||||
|
||||
# CSP — keep this SHA-256 in sync with your inline pre-theme script
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Caching ----------------
|
||||
<IfModule mod_headers.c>
|
||||
# HTML: always revalidate
|
||||
<FilesMatch "\.(html|htm)$">
|
||||
# HTML/PHP: no cache
|
||||
<FilesMatch "\.(html?|php)$">
|
||||
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header setifempty Pragma "no-cache"
|
||||
Header setifempty Expires "0"
|
||||
</FilesMatch>
|
||||
|
||||
# version.js: never cache
|
||||
<FilesMatch "^js/version\.js$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
# JS/CSS: short‑term cache, revalidate regularly
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||
|
||||
# JS/CSS: long cache if ?v= present, else 1h
|
||||
<FilesMatch "\.(?:m?js|css)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
|
||||
</FilesMatch>
|
||||
|
||||
# Images/fonts: long cache if ?v= present, else 7d
|
||||
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=604800" env=!IS_VER
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Additional Security Headers
|
||||
# -----------------------------
|
||||
<IfModule mod_headers.c>
|
||||
# Enforce HTTPS for a year with subdomains and preload option.
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# Set a Referrer Policy.
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Permissions Policy: disable features you don't need.
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
# IE-specific header to prevent downloads from opening in IE.
|
||||
Header always set X-Download-Options "noopen"
|
||||
# Expect-CT header for Certificate Transparency (optional).
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
# ---------------- Compression ----------------
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------
|
||||
# Disable TRACE method
|
||||
# -----------------------------
|
||||
# ---------------- Disable TRACE ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||
RewriteRule .* - [F]
|
||||
RewriteRule .* - [F]
|
||||
</IfModule>
|
||||
@@ -19,13 +19,11 @@ if (isset($_GET['spec'])) {
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FileRise API Docs</title>
|
||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||
integrity="sha384-70P5pmIdaQdVbxvjhrcTDv1uKcKqalZ3OHi7S2J+uzDl0PW8dO6L+pHOpm9EEjGJ"
|
||||
crossorigin="anonymous"></script>
|
||||
<script defer src="/js/redoc-init.js"></script>
|
||||
<script defer src="/vendor/redoc/redoc.standalone.js?v={{APP_QVER}}"></script>
|
||||
<script defer src="/js/redoc-init.js?v={{APP_QVER}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="api.php?spec=1"></redoc>
|
||||
<redoc spec-url="/api.php?spec=1"></redoc>
|
||||
<div id="redoc-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +1,5 @@
|
||||
<?php
|
||||
// public/api/admin/acl/getGrants.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/admin/acl/getGrants.php",
|
||||
* summary="Get ACL grants for a user",
|
||||
* tags={"Admin","ACL"},
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\Parameter(name="user", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Map of folder → grant flags",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* required={"grants"},
|
||||
* @OA\Property(property="grants", ref="#/components/schemas/GrantsMap")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid user"),
|
||||
* @OA\Response(response=401, description="Unauthorized")
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
@@ -32,7 +9,6 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Admin only
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
|
||||
}
|
||||
@@ -55,7 +31,7 @@ try {
|
||||
} catch (Throwable $e) { /* ignore */ }
|
||||
|
||||
if (empty($folders)) {
|
||||
$aclPath = META_DIR . 'folder_acl.json';
|
||||
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
if (is_file($aclPath)) {
|
||||
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||
if (is_array($data['folders'] ?? null)) {
|
||||
@@ -74,29 +50,36 @@ $has = function(array $arr, string $u): bool {
|
||||
|
||||
$out = [];
|
||||
foreach ($folderList as $f) {
|
||||
$rec = ACL::explicit($f); // owners, read, write, share, read_own
|
||||
$rec = ACL::explicitAll($f); // legacy + granular
|
||||
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user);
|
||||
|
||||
// IMPORTANT: full view only if owner or explicit read
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||
|
||||
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
|
||||
$canViewOwn = $has($rec['read_own'], $user);
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
||||
|
||||
// Share only if owner or explicit share
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
|
||||
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
|
||||
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
||||
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|
||||
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|
||||
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
|
||||
$out[$f] = [
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'upload' => $canUpload,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare,
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'write' => $has($rec['write'], $user) || $isOwner,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare, // legacy
|
||||
'create' => $isOwner || $has($rec['create'], $user),
|
||||
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
|
||||
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
|
||||
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
|
||||
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
|
||||
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
|
||||
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
|
||||
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
|
||||
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
|
||||
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
<?php
|
||||
// public/api/admin/acl/saveGrants.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/admin/acl/saveGrants.php",
|
||||
* summary="Save ACL grants (single-user or batch)",
|
||||
* tags={"Admin","ACL"},
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* description="Either {user,grants} or {changes:[{user,grants}]}",
|
||||
* @OA\JsonContent(oneOf={
|
||||
* @OA\Schema(ref="#/components/schemas/SaveGrantsSingle"),
|
||||
* @OA\Schema(ref="#/components/schemas/SaveGrantsBatch")
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Saved"),
|
||||
* @OA\Response(response=400, description="Invalid payload"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Invalid CSRF")
|
||||
* )
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
@@ -47,22 +25,38 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
/**
|
||||
* Sanitize a grants map to allowed flags only:
|
||||
* view | viewOwn | upload | manage | share
|
||||
*/
|
||||
function normalize_caps(array $row): array {
|
||||
// booleanize known keys
|
||||
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
|
||||
$k = [
|
||||
'view','viewOwn','upload','manage','share',
|
||||
'create','edit','rename','copy','move','delete','extract',
|
||||
'shareFile','shareFolder','write'
|
||||
];
|
||||
$out = [];
|
||||
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
|
||||
|
||||
// BUSINESS RULES:
|
||||
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
|
||||
if ($out['shareFolder'] && !$out['view']) {
|
||||
$out['view'] = true;
|
||||
}
|
||||
|
||||
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
|
||||
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
||||
$out['viewOwn'] = true;
|
||||
}
|
||||
|
||||
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
|
||||
return $out;
|
||||
}
|
||||
|
||||
function sanitize_grants_map(array $grants): array {
|
||||
$allowed = ['view','viewOwn','upload','manage','share'];
|
||||
$out = [];
|
||||
foreach ($grants as $folder => $caps) {
|
||||
if (!is_string($folder)) $folder = (string)$folder;
|
||||
if (!is_array($caps)) $caps = [];
|
||||
$row = [];
|
||||
foreach ($allowed as $k) {
|
||||
$row[$k] = !empty($caps[$k]);
|
||||
}
|
||||
// include folder even if all false (signals "remove all for this user on this folder")
|
||||
$out[$folder] = $row;
|
||||
$out[$folder] = normalize_caps($caps);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -124,4 +118,4 @@ if (isset($in['changes']) && is_array($in['changes'])) {
|
||||
|
||||
// ---- Fallback --------------------------------------------------------------
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||
|
||||
24
public/api/file/downloadZipFile.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// public/api/file/downloadZipFile.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/downloadZipFile.php",
|
||||
* summary="Download a finished ZIP by token",
|
||||
* description="Streams the zip once; token is one-shot.",
|
||||
* operationId="downloadZipFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
|
||||
* @OA\Response(response=200, description="ZIP stream"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$controller = new FileController();
|
||||
$controller->downloadZipFile();
|
||||
23
public/api/file/zipStatus.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// public/api/file/zipStatus.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/zipStatus.php",
|
||||
* summary="Check status of a background ZIP build",
|
||||
* description="Returns status for the authenticated user's token.",
|
||||
* operationId="zipStatus",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||
* @OA\Response(response=200, description="Status payload"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$controller = new FileController();
|
||||
$controller->zipStatus();
|
||||
@@ -51,7 +51,7 @@
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
@@ -88,30 +88,50 @@ function loadPermsFor(string $u): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isAdminUser(string $u, array $perms): bool {
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
|
||||
$role = $_SESSION['role'] ?? null;
|
||||
if ($role === 'admin' || $role === '1' || $role === 1) return true;
|
||||
if ($u) {
|
||||
$r = userModel::getUserRole($u);
|
||||
if ($r === '1') return true;
|
||||
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
// direct owner
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
// ancestor owner
|
||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||
$pos = strrpos($f, '/');
|
||||
if ($pos === false) break;
|
||||
$f = substr($f, 0, $pos);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* folder-only scope:
|
||||
* - Admins: always in scope
|
||||
* - Non folder-only accounts: always in scope
|
||||
* - Folder-only accounts: in scope iff:
|
||||
* - folder == username OR subpath of username, OR
|
||||
* - user is owner of this folder (or any ancestor)
|
||||
*/
|
||||
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
if (!$folderOnly) return true;
|
||||
$f = trim($folder);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
|
||||
return ($f === $u) || (strpos($f, $u . '/') === 0);
|
||||
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
//if (!$folderOnly) return true;
|
||||
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if ($f === 'root' || $f === '') {
|
||||
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||
|
||||
// Treat ownership as in-scope
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
|
||||
|
||||
// validate folder path
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
@@ -129,44 +149,97 @@ if ($folder !== 'root') {
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = isAdminUser($username, $perms);
|
||||
// --- user + flags ---
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = ACL::isAdmin($perms);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
|
||||
// base permissions via ACL
|
||||
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
// --- ACL base abilities ---
|
||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
|
||||
// scope + flags
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$disableUpload = !empty($perms['disableUpload']);
|
||||
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
||||
|
||||
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
|
||||
$canCreateFolder = $canWrite && !$readOnly && $inScope;
|
||||
$canRename = $canWrite && !$readOnly && $inScope;
|
||||
$canDelete = $canWrite && !$readOnly && $inScope;
|
||||
$canMoveIn = $canWrite && !$readOnly && $inScope;
|
||||
// granular base
|
||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||
|
||||
// (optional) owner info if you need it client-side
|
||||
$owner = FolderModel::getOwnerFor($folder);
|
||||
// --- Apply scope + flags to effective UI actions ---
|
||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||
// Destination can receive items if user can create/write (or manage) here
|
||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||
$canMoveIn = $canReceive;
|
||||
$canMoveAlias = $canMoveIn;
|
||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||
|
||||
// Sharing respects scope; optionally also gate on readOnly
|
||||
$canShare = $canShareBase && $inScope; // legacy umbrella
|
||||
$canShareFileEff = $gShareFile && $inScope;
|
||||
$canShareFoldEff = $gShareFolder && $inScope;
|
||||
|
||||
// never allow destructive ops on root
|
||||
$isRoot = ($folder === 'root');
|
||||
if ($isRoot) {
|
||||
$canRename = false;
|
||||
$canDelete = false;
|
||||
$canShareFoldEff = false;
|
||||
$canMoveFolder = false;
|
||||
}
|
||||
|
||||
if (!$isRoot) {
|
||||
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||
&& !$readOnly;
|
||||
}
|
||||
|
||||
$owner = null;
|
||||
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
|
||||
|
||||
// output
|
||||
echo json_encode([
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'readOnly' => $readOnly,
|
||||
'disableUpload' => $disableUpload,
|
||||
],
|
||||
'owner' => $owner,
|
||||
'canView' => $canRead,
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreateFolder,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canShare' => $canShare,
|
||||
'owner' => $owner,
|
||||
|
||||
// viewing
|
||||
'canView' => $canView,
|
||||
'canViewOwn' => $canViewOwn,
|
||||
|
||||
// write-ish
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreate,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canMove' => $canMoveAlias,
|
||||
'canMoveFolder'=> $canMoveFolder,
|
||||
'canEdit' => $canEdit,
|
||||
'canCopy' => $canCopy,
|
||||
'canExtract' => $canExtract,
|
||||
|
||||
// sharing
|
||||
'canShare' => $canShare, // legacy
|
||||
'canShareFile' => $canShareFileEff,
|
||||
'canShareFolder' => $canShareFoldEff,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
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();
|
||||
7
public/api/media/getProgress.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/getProgress.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->getProgress();
|
||||
7
public/api/media/getViewedMap.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/getViewedMap.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->getViewedMap();
|
||||
7
public/api/media/updateProgress.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/updateProgress.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->updateProgress();
|
||||
13
public/api/onlyoffice/callback.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/onlyoffice/callback.php",
|
||||
* summary="ONLYOFFICE save callback",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="OK / error JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->callback();
|
||||
17
public/api/onlyoffice/config.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/config.php",
|
||||
* summary="Get editor config for a file (signed URLs, callback)",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="Editor config"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=404, description="Disabled / Not found")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->config();
|
||||
15
public/api/onlyoffice/signed-download.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/signed-download.php",
|
||||
* summary="Serve a signed file blob to ONLYOFFICE",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="File stream"),
|
||||
* @OA\Response(response=403, description="Signature/expiry invalid")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->signedDownload();
|
||||
13
public/api/onlyoffice/status.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/status.php",
|
||||
* summary="ONLYOFFICE availability & supported extensions",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="Status JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->status();
|
||||
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();
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/icons/base-1024.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/assets/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
24
public/css/vendor/material-icons.css
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/* fallback */
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
44
public/css/vendor/roboto.css
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Roboto Regular 400 — latin-ext */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:400;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
/* Roboto Regular 400 — latin */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:400;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
/* Roboto Medium 500 — latin-ext */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:500;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
/* Roboto Medium 500 — latin */
|
||||
@font-face{
|
||||
font-family:'Roboto';
|
||||
font-style:normal;
|
||||
font-weight:500;
|
||||
font-display:swap;
|
||||
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
|
||||
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
/* sensible stack so Chinese falls back cleanly */
|
||||
:root{
|
||||
--ui-font: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
body{ font-family: var(--ui-font); }
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
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
@@ -2,142 +2,67 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FileRise</title>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<meta name="csrf-token" content="">
|
||||
<meta name="share-url" content="">
|
||||
<style>
|
||||
/* hide the app shell until JS says otherwise */
|
||||
.main-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* full-screen white overlay while we check auth */
|
||||
#loadingOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-color, #fff);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||
<style id="pretheme-css">
|
||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||
</style>
|
||||
<!-- Google Fonts and Material Icons -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
|
||||
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
|
||||
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"
|
||||
integrity="sha384-xPpkMo5nDgD98fIcuRVYhxkZV6/9Y4L8s3p0J5c4MxgJkyKJ8BJr+xfRkq7kn6Tw"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"
|
||||
integrity="sha384-to8njsu2GAiXQnY/aLGzz0DIY/SFSeSDodtvSl869n2NmsBdHOTZNNqbEBPYh7Pa"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"
|
||||
integrity="sha384-kmQrbJf09Uo1WRLMDVGoVG3nM6F48frIhcj7f3FDUjeRzsiHwyBWDjMUIttnIeAf"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"
|
||||
integrity="sha384-EXTg7rRfdTPZWoKVCslusAAev2TYw76fm+Wox718iEtFQ+gdAdAc5Z/ndLHSo4mq"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
|
||||
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
||||
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
||||
crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||
|
||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||
|
||||
<!-- Critical CSS -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Fonts (ok to keep as real preloads) -->
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Vendor & version (deferred) -->
|
||||
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
<!-- App entry -->
|
||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="appRoot" style="visibility:hidden">
|
||||
<header class="header-container">
|
||||
|
||||
<div class="header-left">
|
||||
<a href="index.html">
|
||||
<div class="header-logo">
|
||||
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve">
|
||||
<defs>
|
||||
<!-- Gradient for the cabinet body -->
|
||||
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Drop shadow filter with animated attributes for a lifting effect -->
|
||||
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow id="dropShadow" dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2">
|
||||
<!-- Animate the vertical offset: from 2 to 1 (as it rises), hold, then back to 2 -->
|
||||
<animate attributeName="dy" values="2;1;1;2" keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
<!-- Animate the blur similarly: from 2 to 1.5 then back to 2 -->
|
||||
<animate attributeName="stdDeviation" values="2;1.5;1.5;2" keyTimes="0;0.2;0.8;1" dur="5s"
|
||||
fill="freeze" />
|
||||
</feDropShadow>
|
||||
</filter>
|
||||
</defs>
|
||||
<style type="text/css">
|
||||
/* Cabinet with gradient, white outline, and drop shadow */
|
||||
.cabinet {
|
||||
fill: url(#cabinetGradient);
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
stroke: #1565C0;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.handle {
|
||||
fill: #1565C0;
|
||||
}
|
||||
</style>
|
||||
<!-- Group that will animate upward and then back down once -->
|
||||
<g id="cabinetGroup">
|
||||
<!-- Cabinet Body with rounded corners, white outline, and drop shadow -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="6" ry="6" class="cabinet" filter="url(#shadowFilter)" />
|
||||
<!-- Divider lines for drawers -->
|
||||
<line x1="5" y1="22" x2="59" y2="22" class="divider" />
|
||||
<line x1="5" y1="34" x2="59" y2="34" class="divider" />
|
||||
<!-- Drawers with Handles -->
|
||||
<rect x="8" y="24" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="27" r="1.5" class="handle" />
|
||||
<rect x="8" y="36" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="39" r="1.5" class="handle" />
|
||||
<rect x="8" y="48" width="48" height="6" rx="1" ry="1" class="drawer" />
|
||||
<circle cx="54" cy="51" r="1.5" class="handle" />
|
||||
<!-- Additional detail: a small top handle on the cabinet door -->
|
||||
<rect x="28" y="10" width="8" height="4" rx="1" ry="1" fill="#1565C0" />
|
||||
<!-- Animate transform: rises by 2 pixels over 1s, holds for 3s, then falls over 1s (total 5s) -->
|
||||
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -2; 0 -2; 0 0"
|
||||
keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
|
||||
</g>
|
||||
</svg>
|
||||
<img
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise"
|
||||
class="logo"
|
||||
width="50"
|
||||
height="50"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="header-title">
|
||||
<h1 data-i18n-key="header_title">FileRise</h1>
|
||||
<h1>FileRise</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||
<!-- Your header drop zone -->
|
||||
|
||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||
<div class="header-buttons">
|
||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||
@@ -172,7 +97,7 @@
|
||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
|
||||
<span class="material-icons" id="darkModeIcon">
|
||||
dark_mode
|
||||
</span>
|
||||
@@ -181,15 +106,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<div id="loadingOverlay"></div>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
|
||||
<main id="main" hidden>
|
||||
<div class="row mt-4" id="loginForm">
|
||||
<div class="col-12">
|
||||
<div id="loginBox" class="login-box">
|
||||
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<form id="authForm" method="post">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername" data-i18n-key="user">User:</label>
|
||||
@@ -199,7 +127,7 @@
|
||||
<label for="loginPassword" data-i18n-key="password">Password:</label>
|
||||
<input type="password" class="form-control" id="loginPassword" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login" data-default>Login</button>
|
||||
<div class="form-group remember-me-container">
|
||||
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
||||
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
|
||||
@@ -215,11 +143,14 @@
|
||||
HTTP
|
||||
Login</a>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||
<div class="main-wrapper">
|
||||
<div class="main-wrapper" hidden>
|
||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
||||
<!-- Main Column -->
|
||||
@@ -286,9 +217,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
|
||||
<i class="material-icons">drive_file_move</i>
|
||||
</button>
|
||||
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
|
||||
<div id="moveFolderModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
|
||||
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
|
||||
into:</p>
|
||||
<select id="moveFolderTarget" class="form-control modal-input"></select>
|
||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelMoveFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
|
||||
<div id="renameFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||
@@ -299,7 +248,7 @@
|
||||
<button id="cancelRenameFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFolder" class="btn btn-primary"
|
||||
data-i18n-key="rename">Rename</button>
|
||||
data-i18n-key="rename" data-default>Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +268,7 @@
|
||||
<button id="cancelDeleteFolder" class="btn btn-secondary"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFolder" class="btn btn-danger"
|
||||
data-i18n-key="delete">Delete</button>
|
||||
data-i18n-key="delete" data-default>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +304,7 @@
|
||||
selected files?</p>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete" data-default>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,7 +318,7 @@
|
||||
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy" data-default>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,41 +332,25 @@
|
||||
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||
data-i18n-key="download_zip">Download ZIP</button>
|
||||
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
|
||||
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
|
||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
||||
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||
<button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n-key="create">Create</span>
|
||||
<span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||
</button>
|
||||
<ul
|
||||
id="createMenu"
|
||||
class="dropdown-menu"
|
||||
style="
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
min-width: 140px;
|
||||
"
|
||||
>
|
||||
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
||||
${t('create_file')}
|
||||
<ul id="createMenu" class="dropdown-menu" style="display:none; position:absolute; top:100%; left:0; margin:4px 0 0; padding:0; list-style:none; background:#fff; border:1px solid #ccc; box-shadow:0 2px 6px rgba(0,0,0,0.2); z-index:10010; min-width:160px;">
|
||||
<li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_file">Create file</span>
|
||||
</li>
|
||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
||||
${t('create_folder')}
|
||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_folder">Create folder</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -425,16 +358,11 @@
|
||||
<div id="createFileModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||
<input
|
||||
type="text"
|
||||
id="createFileNameInput"
|
||||
class="form-control"
|
||||
placeholder="Enter filename…"
|
||||
data-i18n-placeholder="newfile_placeholder"
|
||||
/>
|
||||
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
|
||||
data-i18n-placeholder="newfile_placeholder" />
|
||||
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
||||
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create" data-default>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +374,7 @@
|
||||
placeholder="files.zip" />
|
||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,14 +412,14 @@
|
||||
placeholder="Filename" />
|
||||
<div style="margin-top: 15px; text-align: right;">
|
||||
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||
@@ -500,7 +428,7 @@
|
||||
placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
|
||||
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;" data-default>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addUserModal" class="modal" style="display:none;">
|
||||
@@ -525,7 +453,7 @@
|
||||
Cancel
|
||||
</button>
|
||||
<!-- Save becomes type="submit" -->
|
||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
|
||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user" data-default>
|
||||
Save User
|
||||
</button>
|
||||
</div>
|
||||
@@ -550,7 +478,7 @@
|
||||
placeholder="Enter new file name" style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename" data-default>Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,12 +486,11 @@
|
||||
<div class="modal-content">
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes">Yes</button>
|
||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes" data-default>Yes</button>
|
||||
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
177
public/js/appCore.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// /js/appCore.js
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||
const _nativeFetch = window.fetch.bind(window);
|
||||
|
||||
/* =========================
|
||||
CSRF UTILITIES (shared)
|
||||
========================= */
|
||||
export function setCsrfToken(token) {
|
||||
if (!token) return;
|
||||
window.csrfToken = token;
|
||||
localStorage.setItem('csrf', token);
|
||||
|
||||
// meta tag for easy access in other places
|
||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'csrf-token';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = token;
|
||||
}
|
||||
|
||||
export function getCsrfToken() {
|
||||
return window.csrfToken || localStorage.getItem('csrf') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap/refresh CSRF from the server.
|
||||
* Uses the native fetch to avoid wrapper loops and accepts rotated tokens via header.
|
||||
*/
|
||||
export async function loadCsrfToken() {
|
||||
const res = await _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
|
||||
|
||||
// header-based rotation
|
||||
const hdr = res.headers.get('X-CSRF-Token');
|
||||
if (hdr) setCsrfToken(hdr);
|
||||
|
||||
// body (if provided)
|
||||
let body = {};
|
||||
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
|
||||
|
||||
const token = body.csrf_token || getCsrfToken();
|
||||
setCsrfToken(token);
|
||||
|
||||
// share-url meta should reflect the actual origin
|
||||
const actualShare = window.location.origin;
|
||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||
if (!shareMeta) {
|
||||
shareMeta = document.createElement('meta');
|
||||
shareMeta.name = 'share-url';
|
||||
document.head.appendChild(shareMeta);
|
||||
}
|
||||
shareMeta.content = actualShare;
|
||||
|
||||
return { csrf_token: token, share_url: actualShare };
|
||||
}
|
||||
|
||||
/* =========================
|
||||
APP INIT (shared)
|
||||
========================= */
|
||||
export function initializeApp() {
|
||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||
|
||||
const last = localStorage.getItem('lastOpenedFolder');
|
||||
window.currentFolder = last ? last : "root";
|
||||
|
||||
const stored = localStorage.getItem('showFoldersInList');
|
||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||
|
||||
// Load public site config early (safe subset)
|
||||
loadAdminConfigFunc();
|
||||
|
||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||
initTagSearch();
|
||||
|
||||
// Hook DnD relay from fileList area into upload area
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
fileListArea.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.add('drop-hover');
|
||||
});
|
||||
fileListArea.addEventListener('dragleave', () => {
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
});
|
||||
fileListArea.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||
dataTransfer: e.dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// App subsystems
|
||||
initDragAndDrop();
|
||||
loadSidebarOrder();
|
||||
loadHeaderOrder();
|
||||
initFileActions();
|
||||
initUpload();
|
||||
loadFolderTree();
|
||||
|
||||
// Only run trash/restore for admins
|
||||
const isAdmin =
|
||||
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||
if (isAdmin) {
|
||||
setupTrashRestoreDelete();
|
||||
}
|
||||
|
||||
// Small help tooltip toggle
|
||||
const helpBtn = document.getElementById("folderHelpBtn");
|
||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||
if (helpBtn && helpTooltip) {
|
||||
helpBtn.addEventListener("click", () => {
|
||||
helpTooltip.style.display =
|
||||
helpTooltip.style.display === "block" ? "none" : "block";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
LOGOUT (shared)
|
||||
========================= */
|
||||
export function triggerLogout() {
|
||||
const clearWelcomeFlags = () => {
|
||||
try {
|
||||
// one-per-tab toast guard
|
||||
sessionStorage.removeItem('__fr_welcomed');
|
||||
// if you also used the per-user (all-tabs) guard, clear that too:
|
||||
const u = localStorage.getItem('username') || '';
|
||||
if (u) localStorage.removeItem(`__fr_welcomed_${u}`);
|
||||
} catch { }
|
||||
};
|
||||
|
||||
_nativeFetch("/api/auth/logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||
})
|
||||
.then(() => {
|
||||
clearWelcomeFlags();
|
||||
window.location.reload(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// even if the request fails, clear the flags so the next login can toast
|
||||
clearWelcomeFlags();
|
||||
window.location.reload(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Global UX guard (unchanged)
|
||||
========================= */
|
||||
window.addEventListener("unhandledrejection", (ev) => {
|
||||
const msg = (ev?.reason && ev.reason.message) || "";
|
||||
if (msg === "auth") {
|
||||
showToast(t("please_sign_in_again") || "Please sign in again.", "error");
|
||||
ev.preventDefault();
|
||||
} else if (msg === "forbidden") {
|
||||
showToast(t("no_access_to_resource") || "You don’t have access to that.", "error");
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { t, applyTranslations } from './i18n.js';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { t, applyTranslations } from './i18n.js?v={{APP_QVER}}';
|
||||
import {
|
||||
toggleVisibility,
|
||||
showToast as originalShowToast,
|
||||
attachEnterKeyListener,
|
||||
showCustomConfirmModal
|
||||
} from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import { renderFileTable } from './fileListView.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
} from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { renderFileTable } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import {
|
||||
openTOTPLoginModal as originalOpenTOTPLoginModal,
|
||||
openUserPanel,
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
closeTOTPModal,
|
||||
setLastLoginData,
|
||||
openApiModal
|
||||
} from './authModals.js';
|
||||
import { openAdminPanel } from './adminPanel.js';
|
||||
import { initializeApp, triggerLogout } from './main.js';
|
||||
} from './authModals.js?v={{APP_QVER}}';
|
||||
import { openAdminPanel } from './adminPanel.js?v={{APP_QVER}}';
|
||||
import { initializeApp, triggerLogout } from './appCore.js?v={{APP_QVER}}';
|
||||
|
||||
// Production OIDC configuration (override via API as needed)
|
||||
const currentOIDCConfig = {
|
||||
@@ -31,6 +31,49 @@ const currentOIDCConfig = {
|
||||
};
|
||||
window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
|
||||
|
||||
(function installToastFilter() {
|
||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
||||
|
||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||
// Suppress the nag while doing TOTP step-up
|
||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return null; // suppress
|
||||
}
|
||||
|
||||
// Demo host
|
||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||
}
|
||||
|
||||
// Try to translate keys; pass through plain text
|
||||
try {
|
||||
const maybe = t(msgKeyOrText);
|
||||
if (typeof maybe === 'string' && maybe !== msgKeyOrText) return maybe;
|
||||
} catch { }
|
||||
return msgKeyOrText;
|
||||
};
|
||||
})();
|
||||
|
||||
function queueWelcomeToast(name) {
|
||||
const uname = String(name || '').trim().slice(0, 80);
|
||||
if (!uname) return;
|
||||
// show immediately (if we don’t reload instantly)
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('filerise:toast', {
|
||||
detail: { message: `Welcome back, ${uname}!`, duration: 2000 }
|
||||
}));
|
||||
} catch { }
|
||||
|
||||
// and persist for after-reload (flushed by main.js on boot)
|
||||
try {
|
||||
sessionStorage.setItem('welcomeMessage', `Welcome back, ${uname}!`);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/* ----------------- TOTP & Toast Overrides ----------------- */
|
||||
// detect if we’re in a pending‑TOTP state
|
||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||
@@ -72,45 +115,51 @@ const originalFetch = window.fetch;
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
|
||||
export async function fetchWithCsrf(url, options = {}) {
|
||||
// 1) Merge in credentials + header
|
||||
options = {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
|
||||
|
||||
options = { credentials: 'include', ...options };
|
||||
options.headers = {
|
||||
...(options.headers || {}),
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Accept': 'application/json',
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
// 2) First attempt
|
||||
let res = await originalFetch(url, options);
|
||||
|
||||
// 3) If we got a 403, try to refresh token & retry
|
||||
if (res.status === 403) {
|
||||
// 3a) See if the server gave us a new token header
|
||||
let newToken = res.headers.get('X-CSRF-Token');
|
||||
// 3b) Otherwise fall back to the /api/auth/token endpoint
|
||||
if (!newToken) {
|
||||
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
||||
if (tokRes.ok) {
|
||||
const body = await tokRes.json();
|
||||
newToken = body.csrf_token;
|
||||
}
|
||||
}
|
||||
if (newToken) {
|
||||
// 3c) Update global + meta
|
||||
window.csrfToken = newToken;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = newToken;
|
||||
|
||||
// 3d) Retry the original request with the new token
|
||||
options.headers['X-CSRF-Token'] = newToken;
|
||||
res = await originalFetch(url, options);
|
||||
}
|
||||
if (window.csrfToken) {
|
||||
options.headers['X-CSRF-Token'] = window.csrfToken;
|
||||
}
|
||||
|
||||
// 4) Return the real Response—no body peeking here!
|
||||
async function retryWithFreshCsrf(asFormFallback = false) {
|
||||
const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
|
||||
if (tokRes.ok) {
|
||||
const body = await tokRes.json().catch(() => ({}));
|
||||
if (body?.csrf_token) {
|
||||
window.csrfToken = body.csrf_token;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = body.csrf_token;
|
||||
options.headers['X-CSRF-Token'] = body.csrf_token;
|
||||
}
|
||||
}
|
||||
if (asFormFallback && wantJson) {
|
||||
// convert JSON body into x-www-form-urlencoded
|
||||
const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
|
||||
options.body = toFormBody(orig);
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
return original(url, options);
|
||||
}
|
||||
|
||||
let res = await original(url, options);
|
||||
|
||||
// If API doesn’t like JSON or token is stale
|
||||
if (res.status === 400 || res.status === 403 || res.status === 415) {
|
||||
// 1) retry with fresh CSRF keeping same encoding
|
||||
res = await retryWithFreshCsrf(false);
|
||||
if (!res.ok && wantJson) {
|
||||
// 2) retry again as form-encoded
|
||||
res = await retryWithFreshCsrf(true);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -180,7 +229,7 @@ function updateLoginOptionsUIFromStorage() {
|
||||
}
|
||||
|
||||
export function loadAdminConfigFunc() {
|
||||
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||
return fetch("/api/siteConfig.php", { credentials: "include" })
|
||||
.then(async (response) => {
|
||||
// If a proxy or some edge returns 204/empty, handle gracefully
|
||||
let config = {};
|
||||
@@ -191,13 +240,13 @@ export function loadAdminConfigFunc() {
|
||||
|
||||
document.title = headerTitle;
|
||||
const lo = config.loginOptions || {};
|
||||
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||
// These may be absent for non-admins; default them
|
||||
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||
|
||||
updateLoginOptionsUIFromStorage();
|
||||
|
||||
@@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (loading) loading.remove();
|
||||
|
||||
// 2) Show main UI
|
||||
document.querySelector('.main-wrapper').style.display = '';
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.querySelector('.main-wrapper').style.display = '';
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
toggleVisibility("loginForm", false);
|
||||
toggleVisibility("mainOperations", true);
|
||||
toggleVisibility("uploadFileForm", true);
|
||||
toggleVisibility("fileListContainer", true);
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||
|
||||
// 3) Persist auth flags (unchanged)
|
||||
@@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
|
||||
localStorage.setItem("username", data.username);
|
||||
}
|
||||
if (typeof data.folderOnly !== "undefined") {
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
||||
}
|
||||
|
||||
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||
@@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
|
||||
|
||||
// 5) Build / update header buttons
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
|
||||
// a) restore-from-trash for admins
|
||||
if (data.isAdmin) {
|
||||
@@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (!r) {
|
||||
r = document.createElement("button");
|
||||
r.id = "restoreFilesBtn";
|
||||
r.classList.add("btn","btn-warning");
|
||||
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||
r.classList.add("btn", "btn-warning");
|
||||
r.setAttribute("data-i18n-title", "trash_restore_delete");
|
||||
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||
if (firstButton) insertAfter(r, firstButton);
|
||||
else headerButtons.appendChild(r);
|
||||
@@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (!a) {
|
||||
a = document.createElement("button");
|
||||
a.id = "adminPanelBtn";
|
||||
a.classList.add("btn","btn-info");
|
||||
a.setAttribute("data-i18n-title","admin_panel");
|
||||
a.classList.add("btn", "btn-info");
|
||||
a.setAttribute("data-i18n-title", "admin_panel");
|
||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||
a.addEventListener("click", openAdminPanel);
|
||||
@@ -330,19 +379,19 @@ export async function updateAuthenticatedUI(data) {
|
||||
: `<i class="material-icons">account_circle</i>`;
|
||||
|
||||
// fallback username if missing
|
||||
const usernameText = data.username
|
||||
|| localStorage.getItem("username")
|
||||
const usernameText = data.username
|
||||
|| localStorage.getItem("username")
|
||||
|| "";
|
||||
|
||||
if (!dd) {
|
||||
dd = document.createElement("div");
|
||||
dd.id = "userDropdown";
|
||||
dd.id = "userDropdown";
|
||||
dd.classList.add("user-dropdown");
|
||||
|
||||
// toggle button
|
||||
const toggle = document.createElement("button");
|
||||
toggle.id = "userDropdownToggle";
|
||||
toggle.classList.add("btn","btn-user");
|
||||
toggle.id = "userDropdownToggle";
|
||||
toggle.classList.add("btn", "btn-user");
|
||||
toggle.setAttribute("title", t("user_settings"));
|
||||
toggle.innerHTML = `
|
||||
${avatarHTML}
|
||||
@@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
|
||||
}
|
||||
updateAuthenticatedUI(data);
|
||||
return data;
|
||||
|
||||
// at the end of updateAuthenticatedUI(data)
|
||||
if (!window.__FR_FLAGS?.initialized && typeof initializeApp === 'function') {
|
||||
initializeApp();
|
||||
window.__FR_FLAGS.initialized = true;
|
||||
}
|
||||
if (typeof applyTranslations === 'function') applyTranslations();
|
||||
if (typeof updateLoginOptionsUIFromStorage === 'function') updateLoginOptionsUIFromStorage();
|
||||
} else {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.remove();
|
||||
@@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
|
||||
}
|
||||
|
||||
/* ----------------- Authentication Submission ----------------- */
|
||||
async function primeCsrfStrict() {
|
||||
const r = await fetch('/api/auth/token.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
|
||||
window.csrfToken = j.csrf_token;
|
||||
const m = document.querySelector('meta[name="csrf-token"]');
|
||||
if (m) m.content = j.csrf_token;
|
||||
}
|
||||
|
||||
function toFormBody(obj) {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v));
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
async function safeJson(res) {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (!/application\/json/i.test(ct)) return null;
|
||||
try { return await res.clone().json(); } catch { return null; }
|
||||
}
|
||||
|
||||
async function sniffTOTP(res, bodyMaybe) {
|
||||
if (res.headers.get('X-TOTP-Required') === '1') return true;
|
||||
if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true;
|
||||
const body = bodyMaybe ?? await safeJson(res);
|
||||
if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true;
|
||||
try {
|
||||
const txt = await res.clone().text();
|
||||
if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true;
|
||||
} catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isAuthedNow() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
return !!j.authenticated;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function rafTick(times = 2) {
|
||||
return new Promise(res => {
|
||||
const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); };
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAuthSnapshot() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
|
||||
return await r.json();
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function syncPermissionsToLocalStorage() {
|
||||
try {
|
||||
const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' });
|
||||
const perm = await r.json();
|
||||
if (perm && typeof perm === 'object') {
|
||||
localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false');
|
||||
localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false');
|
||||
localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false');
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ——— main ———
|
||||
let __loginInFlight = false;
|
||||
|
||||
async function submitLogin(data) {
|
||||
setLastLoginData(data);
|
||||
window.__lastLoginData = data;
|
||||
if (__loginInFlight) return;
|
||||
__loginInFlight = true;
|
||||
|
||||
const payload = {
|
||||
username: String(data.username || '').trim(),
|
||||
password: String(data.password || '').trim(),
|
||||
remember_me: data.remember_me ? 1 : 0
|
||||
};
|
||||
|
||||
setLastLoginData(payload);
|
||||
window.__lastLoginData = payload;
|
||||
|
||||
try {
|
||||
// ─── 1) Get CSRF for the initial auth call ───
|
||||
let res = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Could not fetch CSRF token");
|
||||
window.csrfToken = (await res.json()).csrf_token;
|
||||
await primeCsrfStrict();
|
||||
|
||||
// ─── 2) Send credentials ───
|
||||
const response = await sendRequest(
|
||||
"/api/auth/auth.php",
|
||||
"POST",
|
||||
data,
|
||||
{ "X-CSRF-Token": window.csrfToken }
|
||||
);
|
||||
// Attempt #1 — JSON
|
||||
let res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let body = await safeJson(res);
|
||||
|
||||
// ─── 3a) Full login (no TOTP) ───
|
||||
if (response.success || response.status === "ok") {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
|
||||
// … fetch permissions & reload …
|
||||
// TOTP requested?
|
||||
if (await sniffTOTP(res, body)) {
|
||||
try { await primeCsrfStrict(); } catch { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
|
||||
if (perm && typeof perm === "object") {
|
||||
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
|
||||
}
|
||||
const auth = await import('/js/auth.js?v={{APP_QVER}}');
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch { }
|
||||
return window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── 3b) TOTP required ───
|
||||
if (response.totp_required) {
|
||||
// **Refresh** CSRF before the TOTP verify call
|
||||
res = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
window.csrfToken = (await res.json()).csrf_token;
|
||||
}
|
||||
// now open the modal—any totp_verify fetch from here on will use the new token
|
||||
return openTOTPLoginModal();
|
||||
// Full success (no TOTP)
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// ─── 3c) Too many attempts ───
|
||||
if (response.error && response.error.includes("Too many failed login attempts")) {
|
||||
showToast(response.error);
|
||||
// Cookie set but non-JSON body — double check session
|
||||
if (!body && await isAuthedNow()) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Attempt #2 — form fallback
|
||||
res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
||||
body: toFormBody(payload)
|
||||
});
|
||||
body = await safeJson(res);
|
||||
|
||||
if (await sniffTOTP(res, body)) {
|
||||
try { await primeCsrfStrict(); } catch { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const auth = await import('/js/auth.js?v={{APP_QVER}}');
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (!body && await isAuthedNow()) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Rate limit still respected
|
||||
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
|
||||
showToast(body.error);
|
||||
const btn = document.querySelector("#authForm button[type='submit']");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
@@ -542,12 +708,12 @@ async function submitLogin(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── 3d) Other failures ───
|
||||
showToast("Login failed: " + (response.error || "Unknown error"));
|
||||
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
|
||||
|
||||
} catch (err) {
|
||||
const msg = err.message || err.error || "Unknown error";
|
||||
showToast(`Login failed: ${msg}`);
|
||||
} catch (e) {
|
||||
showToast('Login failed: ' + (e.message || 'Unknown error'));
|
||||
} finally {
|
||||
__loginInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
});
|
||||
|
||||
export { initAuth, checkAuthentication };
|
||||
export { initAuth, checkAuthentication, openTOTPLoginModal };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
let lastLoginData = null;
|
||||
export function setLastLoginData(data) {
|
||||
@@ -328,10 +328,19 @@ export async function openUserPanel() {
|
||||
const langSel = document.createElement('select');
|
||||
langSel.id = 'languageSelector';
|
||||
langSel.className = 'form-select';
|
||||
['en', 'es', 'fr', 'de'].forEach(code => {
|
||||
const languages = [
|
||||
{ code: 'en', labelKey: 'english', fallback: 'English' },
|
||||
{ code: 'es', labelKey: 'spanish', fallback: 'Español' },
|
||||
{ code: 'fr', labelKey: 'french', fallback: 'Français' },
|
||||
{ code: 'de', labelKey: 'german', fallback: 'Deutsch' },
|
||||
{ code: 'zh-CN', labelKey: 'chinese_simplified', fallback: '简体中文' },
|
||||
];
|
||||
|
||||
languages.forEach(({ code, labelKey, fallback }) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = code;
|
||||
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
|
||||
// use i18n if available, otherwise fallback
|
||||
opt.textContent = (typeof t === 'function' ? t(labelKey) : '') || fallback;
|
||||
langSel.appendChild(opt);
|
||||
});
|
||||
langSel.value = localStorage.getItem('language') || 'en';
|
||||
|
||||
31
public/js/defer-css.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// /public/js/defer-css.js
|
||||
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
|
||||
(function () {
|
||||
if (window.__CSS_PROMISE__) return;
|
||||
|
||||
var loads = [];
|
||||
|
||||
// Promote <link rel="preload" as="style"> IN-PLACE
|
||||
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
|
||||
for (var i = 0; i < preloads.length; i++) {
|
||||
var l = preloads[i];
|
||||
// resolve when it finishes loading as a stylesheet
|
||||
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
|
||||
l.rel = 'stylesheet';
|
||||
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
|
||||
l.removeAttribute('as'); // keep some engines happy about "used" preload
|
||||
}
|
||||
|
||||
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
|
||||
var styles = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (var j = 0; j < styles.length; j++) {
|
||||
var s = styles[j];
|
||||
if (s.sheet) continue; // already applied
|
||||
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
|
||||
}
|
||||
|
||||
// Safari quirk: nudge layout so promoted sheets apply immediately
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
window.__CSS_PROMISE__ = Promise.all(loads);
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
// domUtils.js
|
||||
import { t } from './i18n.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
|
||||
// Basic DOM Helpers
|
||||
export function toggleVisibility(elementId, shouldShow) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// fileActions.js
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { formatFolderName } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
e.preventDefault();
|
||||
@@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
||||
if (confirmDelete) {
|
||||
confirmDelete.setAttribute("data-default", "");
|
||||
confirmDelete.addEventListener("click", function () {
|
||||
fetch("/api/file/deleteFiles.php", {
|
||||
method: "POST",
|
||||
@@ -118,7 +119,7 @@ export async function handleCreateFile(e) {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type':'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken
|
||||
},
|
||||
// ⚠️ must send `name`, not `filename`
|
||||
@@ -138,7 +139,7 @@ export async function handleCreateFile(e) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cancel = document.getElementById('cancelCreateFile');
|
||||
const confirm = document.getElementById('confirmCreateFile');
|
||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||
});
|
||||
|
||||
@@ -264,7 +265,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||
|
||||
|
||||
if (cancelCreate) {
|
||||
cancelCreate.addEventListener('click', () => {
|
||||
document.getElementById('createFileModal').style.display = 'none';
|
||||
@@ -304,7 +305,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
}
|
||||
});
|
||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||
}
|
||||
|
||||
// 1) Cancel button hides the name modal
|
||||
@@ -316,66 +317,191 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// 2) Confirm button kicks off the zip+download
|
||||
if (confirmZipBtn) {
|
||||
confirmZipBtn.setAttribute("data-default", "");
|
||||
confirmZipBtn.addEventListener("click", async () => {
|
||||
// a) Validate ZIP filename
|
||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||
if (!zipName) {
|
||||
showToast("Please enter a name for the zip file.");
|
||||
return;
|
||||
}
|
||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
||||
zipName += ".zip";
|
||||
}
|
||||
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
|
||||
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
|
||||
|
||||
// b) Hide the name‐input modal, show the spinner modal
|
||||
// b) Hide the name‐input modal, show the progress modal
|
||||
zipNameModal.style.display = "none";
|
||||
progressModal.style.display = "block";
|
||||
|
||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
||||
// c) Title text (optional)
|
||||
const titleEl = document.getElementById("downloadProgressTitle");
|
||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||
|
||||
try {
|
||||
// d) POST and await the ZIP blob
|
||||
const res = await fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: window.currentFolder || "root",
|
||||
files: window.filesToDownload
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || `Status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Received empty ZIP file.");
|
||||
}
|
||||
|
||||
// e) Hand off to the browser’s download manager
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = zipName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error downloading ZIP:", err);
|
||||
showToast("Error: " + err.message);
|
||||
} finally {
|
||||
// f) Always hide spinner modal
|
||||
progressModal.style.display = "none";
|
||||
// d) Queue the job
|
||||
const res = await fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
|
||||
});
|
||||
const jsr = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !jsr.ok) {
|
||||
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
const token = jsr.token;
|
||||
const statusUrl = jsr.statusUrl;
|
||||
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
|
||||
|
||||
// Ensure a progress UI exists in the modal
|
||||
function ensureZipProgressUI() {
|
||||
const modalEl = document.getElementById("downloadProgressModal");
|
||||
if (!modalEl) {
|
||||
// really shouldn't happen, but fall back to body
|
||||
console.warn("downloadProgressModal not found; falling back to document.body");
|
||||
}
|
||||
// Prefer a dedicated content node inside the modal
|
||||
let host =
|
||||
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
|
||||
(modalEl && modalEl.querySelector(".modal-body")) ||
|
||||
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
|
||||
(modalEl && modalEl.querySelector(".modal-content")) ||
|
||||
(modalEl && modalEl.querySelector(".content")) ||
|
||||
null;
|
||||
|
||||
// If no suitable container, create one inside the modal
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.id = "downloadProgressContent";
|
||||
(modalEl || document.body).appendChild(host);
|
||||
}
|
||||
|
||||
// Helper: ensure/move an element with given id into host
|
||||
function ensureInHost(id, tag, init) {
|
||||
let el = document.getElementById(id);
|
||||
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
|
||||
if (!el) {
|
||||
el = document.createElement(tag);
|
||||
el.id = id;
|
||||
if (typeof init === "function") init(el);
|
||||
host.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Title
|
||||
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
|
||||
el.style.marginBottom = "8px";
|
||||
el.textContent = "Preparing…";
|
||||
});
|
||||
|
||||
// Progress bar (native <progress>)
|
||||
const bar = (function () {
|
||||
let el = document.getElementById("downloadProgressBar");
|
||||
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
|
||||
if (!el) {
|
||||
el = document.createElement("progress");
|
||||
el.id = "downloadProgressBar";
|
||||
host.appendChild(el);
|
||||
}
|
||||
el.max = 100;
|
||||
el.value = 0;
|
||||
el.style.display = ""; // override any inline display:none
|
||||
el.style.width = "100%";
|
||||
el.style.height = "1.1em";
|
||||
return el;
|
||||
})();
|
||||
|
||||
// Text line
|
||||
const text = ensureInHost("downloadProgressText", "div", (el) => {
|
||||
el.style.marginTop = "8px";
|
||||
el.style.fontSize = "0.9rem";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.overflow = "hidden";
|
||||
el.style.textOverflow = "ellipsis";
|
||||
});
|
||||
|
||||
// Optional spinner hider
|
||||
const hideSpinner = () => {
|
||||
const sp = document.getElementById("downloadSpinner");
|
||||
if (sp) sp.style.display = "none";
|
||||
};
|
||||
|
||||
return { bar, text, title, hideSpinner };
|
||||
}
|
||||
|
||||
function humanBytes(n) {
|
||||
if (!Number.isFinite(n) || n < 0) return "";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
|
||||
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
||||
}
|
||||
function mmss(sec) {
|
||||
sec = Math.max(0, sec | 0);
|
||||
const m = (sec / 60) | 0, s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const ui = ensureZipProgressUI();
|
||||
const t0 = Date.now();
|
||||
|
||||
// e) Poll until ready
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
|
||||
credentials: "include", cache: "no-store",
|
||||
}).then(r => r.json());
|
||||
|
||||
if (s.error) throw new Error(s.error);
|
||||
if (ui.title) ui.title.textContent = `Preparing ${zipName}…`;
|
||||
|
||||
// --- RENDER PROGRESS ---
|
||||
if (typeof s.pct === "number" && ui.bar && ui.text) {
|
||||
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
|
||||
ui.hideSpinner && ui.hideSpinner();
|
||||
const filesDone = s.filesDone ?? 0;
|
||||
const filesTotal = s.filesTotal ?? 0;
|
||||
const bytesDone = s.bytesDone ?? 0;
|
||||
const bytesTotal = s.bytesTotal ?? 0;
|
||||
|
||||
// Determinate 0–98% while enumerating
|
||||
const pct = Math.max(0, Math.min(98, s.pct | 0));
|
||||
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
|
||||
ui.bar.value = pct;
|
||||
ui.text.textContent =
|
||||
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
|
||||
} else {
|
||||
// FINALIZING: keep progress at 100% and show timer + selected totals
|
||||
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
|
||||
ui.bar.value = 100; // lock at 100 during finalizing
|
||||
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
|
||||
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
|
||||
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
|
||||
ui.text.textContent = `Finalizing… ${mmss(since)} — ${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
|
||||
}
|
||||
} else if (ui.text) {
|
||||
ui.text.textContent = "Still preparing…";
|
||||
}
|
||||
// --- /RENDER ---
|
||||
|
||||
if (s.ready) {
|
||||
// Snap to 100 and close modal just before download
|
||||
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
|
||||
progressModal.style.display = "none";
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
break;
|
||||
}
|
||||
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
|
||||
}
|
||||
|
||||
// f) Trigger download
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
a.download = zipName;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
// g) Reset for next time
|
||||
if (ui.bar) ui.bar.value = 0;
|
||||
if (ui.text) ui.text.textContent = "";
|
||||
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -478,6 +604,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
const confirmCopy = document.getElementById("confirmCopyFiles");
|
||||
if (confirmCopy) {
|
||||
confirmCopy.setAttribute("data-default", "");
|
||||
confirmCopy.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("copyTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
@@ -529,6 +656,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
const confirmMove = document.getElementById("confirmMoveFiles");
|
||||
if (confirmMove) {
|
||||
confirmMove.setAttribute("data-default", "");
|
||||
confirmMove.addEventListener("click", function () {
|
||||
const targetFolder = document.getElementById("moveTargetFolder").value;
|
||||
if (!targetFolder) {
|
||||
@@ -598,6 +726,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const submitBtn = document.getElementById("submitRenameFile");
|
||||
if (submitBtn) {
|
||||
submitBtn.setAttribute("data-default", "");
|
||||
submitBtn.addEventListener("click", function () {
|
||||
const newName = document.getElementById("newFileName").value.trim();
|
||||
if (!newName || newName === window.fileToRename) {
|
||||
@@ -689,10 +818,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('createBtn');
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt= document.getElementById('createFolderOption');
|
||||
const btn = document.getElementById('createBtn');
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt = document.getElementById('createFolderOption');
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
|
||||
@@ -1,125 +1,164 @@
|
||||
// fileDragDrop.js
|
||||
import { showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = event.currentTarget;
|
||||
let fileNames = [];
|
||||
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 1) {
|
||||
selectedCheckboxes.forEach(chk => {
|
||||
const parentRow = chk.closest("tr");
|
||||
if (parentRow) {
|
||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
||||
if (cell) {
|
||||
let rawName = cell.textContent.trim();
|
||||
const tagContainer = cell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
||||
if (fileNameCell) {
|
||||
let rawName = fileNameCell.textContent.trim();
|
||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNames.length === 0) return;
|
||||
|
||||
const dragData = fileNames.length === 1
|
||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
||||
|
||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
|
||||
let dragImage = document.createElement("div");
|
||||
dragImage.style.display = "inline-flex";
|
||||
dragImage.style.width = "auto";
|
||||
dragImage.style.maxWidth = "fit-content";
|
||||
dragImage.style.padding = "6px 10px";
|
||||
dragImage.style.backgroundColor = "#333";
|
||||
dragImage.style.color = "#fff";
|
||||
dragImage.style.border = "1px solid #555";
|
||||
dragImage.style.borderRadius = "4px";
|
||||
dragImage.style.alignItems = "center";
|
||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-icons";
|
||||
icon.textContent = "insert_drive_file";
|
||||
icon.style.marginRight = "4px";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
||||
dragImage.appendChild(icon);
|
||||
dragImage.appendChild(label);
|
||||
|
||||
document.body.appendChild(dragImage);
|
||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(dragImage);
|
||||
}, 0);
|
||||
/* ---------------- helpers ---------------- */
|
||||
function getRowEl(el) {
|
||||
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||
}
|
||||
function getNameFromAny(el) {
|
||||
const row = getRowEl(el);
|
||||
if (!row) return null;
|
||||
// 1) canonical
|
||||
const n = row.getAttribute('data-file-name');
|
||||
if (n) return n;
|
||||
// 2) filename-only span
|
||||
const span = row.querySelector('.filename-text');
|
||||
if (span) return span.textContent.trim();
|
||||
return null;
|
||||
}
|
||||
function getSelectedFileNames() {
|
||||
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||
// de-dup just in case
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||
const wrap = document.createElement('div');
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
maxWidth: '420px',
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '6px',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons';
|
||||
icon.textContent = iconName;
|
||||
const label = document.createElement('span');
|
||||
// trim long single-name labels
|
||||
const txt = String(labelText || '');
|
||||
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||
wrap.appendChild(icon);
|
||||
wrap.appendChild(label);
|
||||
document.body.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---------------- drag start (rows/cards) ---------------- */
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = getRowEl(event.currentTarget);
|
||||
if (!row) return;
|
||||
|
||||
// Use current selection if present; otherwise drag just this row’s file
|
||||
let names = getSelectedFileNames();
|
||||
if (names.length === 0) {
|
||||
const single = getNameFromAny(row);
|
||||
if (single) names = [single];
|
||||
}
|
||||
if (names.length === 0) return;
|
||||
|
||||
const sourceFolder = window.currentFolder || 'root';
|
||||
const payload = { files: names, sourceFolder };
|
||||
|
||||
// primary payload
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
// fallback (lets some environments read something human)
|
||||
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||
|
||||
// nicer drag image
|
||||
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||
// clean up the ghost as soon as the browser has captured it
|
||||
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||
}
|
||||
|
||||
/* ---------------- folder targets ---------------- */
|
||||
export function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
event.currentTarget.classList.add('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDropHandler(event) {
|
||||
export async function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
|
||||
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||
|| 'root';
|
||||
|
||||
// parse drag payload
|
||||
let dragData = null;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data");
|
||||
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||
dragData = JSON.parse(raw);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!dragData) {
|
||||
showToast('Invalid drag data.');
|
||||
return;
|
||||
}
|
||||
if (!dragData || !dragData.fileName) return;
|
||||
fetch("/api/file/moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: [dragData.fileName],
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving file via drop:", error);
|
||||
showToast("Error moving file.");
|
||||
|
||||
// normalize names
|
||||
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||
: dragData.fileName ? [dragData.fileName]
|
||||
: [];
|
||||
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||
|
||||
if (names.length === 0) {
|
||||
showToast('No files to move.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||
if (dropFolder === sourceFolder) {
|
||||
showToast('Source and destination are the same.');
|
||||
return;
|
||||
}
|
||||
|
||||
// POST move
|
||||
try {
|
||||
const res = await fetch('/api/file/moveFiles.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: sourceFolder,
|
||||
files: names,
|
||||
destination: dropFolder
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && data && data.success) {
|
||||
const msg = (names.length === 1)
|
||||
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||
showToast(msg);
|
||||
// Refresh whatever view the user is currently looking at
|
||||
loadFileList(window.currentFolder || sourceFolder);
|
||||
} else {
|
||||
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||
showToast('Error moving file(s): ' + err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error moving file(s):', e);
|
||||
showToast('Error moving file(s).');
|
||||
}
|
||||
}
|
||||
@@ -1,147 +1,503 @@
|
||||
// fileEditor.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
|
||||
// thresholds for editor behavior
|
||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||
|
||||
// Lazy-load CodeMirror modes on demand
|
||||
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||
const MODE_URL = {
|
||||
// core you've likely already loaded:
|
||||
"xml": "mode/xml/xml.min.js",
|
||||
"css": "mode/css/css.min.js",
|
||||
"javascript": "mode/javascript/javascript.min.js",
|
||||
// ==== CodeMirror lazy loader ===============================================
|
||||
const CM_BASE = "/vendor/codemirror/5.65.5/";
|
||||
|
||||
// extras you may want on-demand:
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||
"application/x-httpd-php": "mode/php/php.min.js",
|
||||
"php": "mode/php/php.min.js",
|
||||
"markdown": "mode/markdown/markdown.min.js",
|
||||
"python": "mode/python/python.min.js",
|
||||
"sql": "mode/sql/sql.min.js",
|
||||
"shell": "mode/shell/shell.min.js",
|
||||
"yaml": "mode/yaml/yaml.min.js",
|
||||
"properties": "mode/properties/properties.min.js",
|
||||
"text/x-csrc": "mode/clike/clike.min.js",
|
||||
"text/x-c++src": "mode/clike/clike.min.js",
|
||||
"text/x-java": "mode/clike/clike.min.js",
|
||||
"text/x-csharp": "mode/clike/clike.min.js",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
|
||||
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
||||
|
||||
const CORE = {
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
css: coreUrl("codemirror.min.css"),
|
||||
themeCss: coreUrl("theme/material-darker.min.css"),
|
||||
};
|
||||
|
||||
function loadScriptOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const key = `cm:${url}`;
|
||||
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||
if (s) {
|
||||
if (s.dataset.loaded === "1") return resolve();
|
||||
s.addEventListener("load", () => resolve());
|
||||
s.addEventListener("error", reject);
|
||||
return;
|
||||
// Which mode file to load for a given name/mime
|
||||
const MODE_URL = {
|
||||
// core/common
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||
|
||||
// meta / combos
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||
|
||||
// docs / data
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
|
||||
// shells
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
|
||||
// languages
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
};
|
||||
|
||||
// Mode dependency graph
|
||||
const MODE_DEPS = {
|
||||
"htmlmixed": ["xml", "javascript", "css"],
|
||||
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
|
||||
"markdown": ["xml"]
|
||||
};
|
||||
|
||||
// Map any mime/alias to the key we use in MODE_URL
|
||||
function normalizeModeName(modeOption) {
|
||||
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||
if (!name) return null;
|
||||
if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML
|
||||
if (name === "php") return "application/x-httpd-php"; // prefer the full mime
|
||||
return name;
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE integration -----------------------------------------------
|
||||
|
||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||
|
||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||
|
||||
async function fetchOnlyOfficeCapsOnce() {
|
||||
if (__ooCaps.fetched) return __ooCaps;
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
__ooCaps.enabled = !!j.enabled;
|
||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||
}
|
||||
s = document.createElement("script");
|
||||
} catch { /* ignore; keep defaults */ }
|
||||
__ooCaps.fetched = true;
|
||||
return __ooCaps;
|
||||
}
|
||||
|
||||
async function shouldUseOnlyOffice(fileName) {
|
||||
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
|
||||
return enabled && exts.has(getExt(fileName));
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||
|
||||
// ---- script/css single-load with timeout guards ----
|
||||
const _loadedScripts = new Set();
|
||||
const _loadedCss = new Set();
|
||||
let _corePromise = null;
|
||||
|
||||
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedScripts.has(url)) return resolve();
|
||||
const s = document.createElement("script");
|
||||
const timer = setTimeout(() => {
|
||||
try { s.remove(); } catch { }
|
||||
reject(new Error(`Timeout loading: ${url}`));
|
||||
}, timeoutMs);
|
||||
s.src = url;
|
||||
s.defer = true;
|
||||
s.dataset.key = key;
|
||||
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||
s.addEventListener("error", reject);
|
||||
s.async = true;
|
||||
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureModeLoaded(modeOption) {
|
||||
if (!window.CodeMirror) return; // CM core must be present
|
||||
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||
if (!name) return;
|
||||
// Already registered?
|
||||
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
|
||||
return;
|
||||
}
|
||||
const url = MODE_URL[name];
|
||||
if (!url) return; // unknown -> fallback to text/plain
|
||||
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
|
||||
if (name === "htmlmixed") {
|
||||
await Promise.all([
|
||||
ensureModeLoaded("xml"),
|
||||
ensureModeLoaded("css"),
|
||||
ensureModeLoaded("javascript")
|
||||
]);
|
||||
}
|
||||
if (name === "application/x-httpd-php") {
|
||||
await ensureModeLoaded("htmlmixed");
|
||||
}
|
||||
await loadScriptOnce(CM_CDN + url);
|
||||
function loadCssOnce(href) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedCss.has(href)) return resolve();
|
||||
const l = document.createElement("link");
|
||||
l.rel = "stylesheet";
|
||||
l.href = href;
|
||||
l.onload = () => { _loadedCss.add(href); resolve(); };
|
||||
l.onerror = () => reject(new Error(`Load failed: ${href}`));
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCore() {
|
||||
if (_corePromise) return _corePromise;
|
||||
_corePromise = (async () => {
|
||||
// load CSS first to avoid FOUC
|
||||
await loadCssOnce(CORE.css);
|
||||
await loadCssOnce(CORE.themeCss);
|
||||
if (!window.CodeMirror) {
|
||||
await loadScriptOnce(CORE.js);
|
||||
}
|
||||
})();
|
||||
return _corePromise;
|
||||
}
|
||||
|
||||
async function loadSingleMode(name) {
|
||||
const rel = MODE_URL[name];
|
||||
if (!rel) return;
|
||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||
await loadScriptOnce(url);
|
||||
}
|
||||
|
||||
function isModeRegistered(name) {
|
||||
return !!(
|
||||
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
|
||||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureModeLoaded(modeOption) {
|
||||
await ensureCore();
|
||||
const name = normalizeModeName(modeOption);
|
||||
if (!name) return;
|
||||
if (isModeRegistered(name)) return;
|
||||
const deps = MODE_DEPS[name] || [];
|
||||
for (const d of deps) {
|
||||
if (!isModeRegistered(d)) await loadSingleMode(d);
|
||||
}
|
||||
await loadSingleMode(name);
|
||||
}
|
||||
|
||||
// Public helper for callers (we keep your existing function name in use):
|
||||
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||
// ==== /CodeMirror lazy loader ===============================================
|
||||
|
||||
// ---- OO preconnect / prewarm ----
|
||||
function injectOOPreconnect(origin) {
|
||||
try {
|
||||
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||
document.head.appendChild(make('dns-prefetch'));
|
||||
document.head.appendChild(make('preconnect'));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||
let src = srcFromConfig;
|
||||
if (!src) {
|
||||
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||
} else {
|
||||
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||
}
|
||||
}
|
||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||
try {
|
||||
console.time('oo:api.js');
|
||||
await loadScriptOnce(src);
|
||||
} catch (e) {
|
||||
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
console.timeEnd('oo:api.js');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||
const OO_WARM_MS = 300;
|
||||
|
||||
function ensureOoModalCss() {
|
||||
const prev = document.getElementById('ooEditorModalCss');
|
||||
if (prev) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ooEditorModalCss';
|
||||
style.textContent = `
|
||||
#ooEditorModal{
|
||||
--oo-header-h: 40px;
|
||||
--oo-header-pad-v: 12px;
|
||||
--oo-header-pad-h: 18px;
|
||||
--oo-logo-h: 26px; /* tweak logo size */
|
||||
}
|
||||
|
||||
#ooEditorModal{
|
||||
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||
background:var(--oo-modal-bg,#111)!important;
|
||||
}
|
||||
|
||||
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||
#ooEditorModal .editor-header{
|
||||
position:relative; display:flex; align-items:center; gap:12px;
|
||||
min-height:var(--oo-header-h);
|
||||
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||
border-bottom:1px solid rgba(0,0,0,.15);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-logo{
|
||||
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||
display:block; user-select:none; -webkit-user-drag:none;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-title{
|
||||
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||
flex:1 1 auto;
|
||||
}
|
||||
|
||||
/* Your scoped close button style */
|
||||
#ooEditorModal .editor-close-btn{
|
||||
position:absolute; top:5px; right:10px;
|
||||
display:flex; justify-content:center; align-items:center;
|
||||
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||
transition:all .3s ease-in-out;
|
||||
}
|
||||
#ooEditorModal .editor-close-btn:hover{
|
||||
color:#fff; background-color:#ff4d4d;
|
||||
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||
}
|
||||
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||
|
||||
#ooEditorModal .editor-body{
|
||||
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||
}
|
||||
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||
|
||||
#ooEditorModal .oo-warm-overlay{
|
||||
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||
}
|
||||
|
||||
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Theme-aware background so there’s no white/gray edge
|
||||
function applyModalBg(modal){
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||
|| (isDark ? '#121212' : '#ffffff');
|
||||
modal.style.setProperty('--oo-modal-bg', bg);
|
||||
}
|
||||
|
||||
function lockPageScroll(on){
|
||||
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||
}
|
||||
|
||||
function ensureOoFullscreenModal(){
|
||||
ensureOoModalCss();
|
||||
let modal = document.getElementById('ooEditorModal');
|
||||
if (!modal){
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'ooEditorModal';
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||
<h3 class="editor-title"></h3>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div id="oo-editor"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
} else {
|
||||
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||
// ensure logo exists and is placed before title when reusing
|
||||
const header = modal.querySelector('.editor-header');
|
||||
if (!header.querySelector('.editor-logo')){
|
||||
const img = document.createElement('img');
|
||||
img.className = 'editor-logo';
|
||||
img.src = '/assets/logo.svg';
|
||||
img.alt = 'FileRise logo';
|
||||
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||
} else {
|
||||
// make sure order is logo -> title
|
||||
const logo = header.querySelector('.editor-logo');
|
||||
const title = header.querySelector('.editor-title');
|
||||
if (logo.nextElementSibling !== title){
|
||||
header.insertBefore(logo, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyModalBg(modal);
|
||||
modal.style.display = 'flex';
|
||||
modal.focus();
|
||||
lockPageScroll(true);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Overlay lives INSIDE the modal body
|
||||
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||
if (!modal) return;
|
||||
const body = modal.querySelector('.editor-body');
|
||||
let ov = body.querySelector('.oo-warm-overlay');
|
||||
if (on){
|
||||
if (!ov){
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'oo-warm-overlay';
|
||||
ov.textContent = label;
|
||||
body.appendChild(ov);
|
||||
}
|
||||
} else if (ov){
|
||||
ov.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||
async function warmDocServerOnce(cfg){
|
||||
let host = null, warmEditor = null;
|
||||
try{
|
||||
host = document.createElement('div');
|
||||
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||
Object.assign(host.style, {
|
||||
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||
});
|
||||
document.body.appendChild(host);
|
||||
|
||||
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||
|
||||
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||
}catch{} finally{
|
||||
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||
try{ host?.remove(); }catch{}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||
async function openOnlyOffice(fileName, folder){
|
||||
let editor = null;
|
||||
let removeThemeListener = () => {};
|
||||
let cfg = null;
|
||||
let userClosed = false;
|
||||
|
||||
// Build our full-screen modal
|
||||
const modal = ensureOoFullscreenModal();
|
||||
const titleEl = modal.querySelector('.editor-title');
|
||||
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||
|
||||
const destroy = (removeModal = true) => {
|
||||
try { editor?.destroyEditor?.(); } catch {}
|
||||
try { removeThemeListener(); } catch {}
|
||||
if (removeModal) { try { modal.remove(); } catch {} }
|
||||
lockPageScroll(false);
|
||||
};
|
||||
const onClose = () => { userClosed = true; destroy(true); };
|
||||
|
||||
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||
|
||||
try{
|
||||
// 1) Fetch config
|
||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
const text = await resp.text();
|
||||
|
||||
try { cfg = JSON.parse(text); } catch {
|
||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||
}
|
||||
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||
|
||||
// 2) Preconnect + load DocsAPI
|
||||
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||
|
||||
// 3) Theme + base events
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||
cfg.editorConfig = cfg.editorConfig || {};
|
||||
cfg.editorConfig.customization = Object.assign(
|
||||
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||
);
|
||||
cfg.events.onRequestClose = () => onClose();
|
||||
|
||||
// 4) Warm EVERY click
|
||||
if (ALWAYS_WARM_OO && !userClosed){
|
||||
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||
await warmDocServerOnce(cfg);
|
||||
if (userClosed) return;
|
||||
}
|
||||
|
||||
// 5) Launch visible editor in full-screen modal
|
||||
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||
|
||||
// Live theme switching + keep modal bg in sync
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
const onDarkToggle = () => {
|
||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||
if (editor && typeof editor.setTheme === 'function') {
|
||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||
}
|
||||
applyModalBg(modal);
|
||||
};
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', onDarkToggle);
|
||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||
}
|
||||
}catch(e){
|
||||
console.error('[ONLYOFFICE] failed to open:', e);
|
||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||
destroy(true);
|
||||
}
|
||||
}
|
||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||
|
||||
// ==== Editor (CodeMirror) path =============================================
|
||||
|
||||
function getModeForFile(fileName) {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||
|
||||
switch (ext) {
|
||||
// markup
|
||||
case "html":
|
||||
case "htm":
|
||||
return "text/html"; // ensureModeLoaded will map to htmlmixed
|
||||
case "xml":
|
||||
return "xml";
|
||||
case "htm": return "text/html";
|
||||
case "xml": return "xml";
|
||||
case "md":
|
||||
case "markdown":
|
||||
return "markdown";
|
||||
case "markdown": return "markdown";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
|
||||
// styles & scripts
|
||||
case "css":
|
||||
return "css";
|
||||
case "js":
|
||||
return "javascript";
|
||||
case "json":
|
||||
return { name: "javascript", json: true };
|
||||
|
||||
// server / langs
|
||||
case "php":
|
||||
return "application/x-httpd-php";
|
||||
case "py":
|
||||
return "python";
|
||||
case "sql":
|
||||
return "sql";
|
||||
case "yaml": return "yaml";
|
||||
case "css": return "css";
|
||||
case "js": return "javascript";
|
||||
case "json": return { name: "javascript", json: true };
|
||||
case "php": return "application/x-httpd-php";
|
||||
case "py": return "python";
|
||||
case "sql": return "sql";
|
||||
case "sh":
|
||||
case "bash":
|
||||
case "zsh":
|
||||
case "bat":
|
||||
return "shell";
|
||||
|
||||
// config-y files
|
||||
case "bat": return "shell";
|
||||
case "ini":
|
||||
case "conf":
|
||||
case "config":
|
||||
case "properties":
|
||||
return "properties";
|
||||
|
||||
// C-family / JVM
|
||||
case "properties": return "properties";
|
||||
case "c":
|
||||
case "h":
|
||||
return "text/x-csrc";
|
||||
case "h": return "text/x-csrc";
|
||||
case "cpp":
|
||||
case "cxx":
|
||||
case "hpp":
|
||||
case "hh":
|
||||
case "hxx":
|
||||
return "text/x-c++src";
|
||||
case "java":
|
||||
return "text/x-java";
|
||||
case "cs":
|
||||
return "text/x-csharp";
|
||||
case "hxx": return "text/x-c++src";
|
||||
case "java": return "text/x-java";
|
||||
case "cs": return "text/x-csharp";
|
||||
case "kt":
|
||||
case "kts":
|
||||
return "text/x-kotlin";
|
||||
|
||||
default:
|
||||
return "text/plain";
|
||||
case "kts": return "text/x-kotlin";
|
||||
default: return "text/plain";
|
||||
}
|
||||
}
|
||||
export { getModeForFile };
|
||||
@@ -158,138 +514,192 @@ export { adjustEditorSize };
|
||||
|
||||
function observeModalResize(modal) {
|
||||
if (!modal) return;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
adjustEditorSize();
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
|
||||
resizeObserver.observe(modal);
|
||||
}
|
||||
export { observeModalResize };
|
||||
|
||||
export function editFile(fileName, folder) {
|
||||
export async function editFile(fileName, folder) {
|
||||
// destroy any previous editor
|
||||
let existingEditor = document.getElementById("editorContainer");
|
||||
if (existingEditor) {
|
||||
existingEditor.remove();
|
||||
}
|
||||
if (existingEditor) existingEditor.remove();
|
||||
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
||||
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const lenHeader =
|
||||
response.headers.get("content-length") ??
|
||||
response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
if (await shouldUseOnlyOffice(fileName)) {
|
||||
await openOnlyOffice(fileName, folderUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||
async function probeSize(url) {
|
||||
try {
|
||||
const h = await fetch(url, { method: "HEAD", credentials: "include" });
|
||||
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
|
||||
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
|
||||
} catch { }
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-0" },
|
||||
credentials: "include"
|
||||
});
|
||||
// Content-Range: bytes 0-0/12345
|
||||
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
|
||||
const m = cr && cr.match(/\/(\d+)\s*$/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
probeSize(fileUrl)
|
||||
.then(sizeBytes => {
|
||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return response;
|
||||
return fetch(fileUrl, { credentials: "include" });
|
||||
})
|
||||
.then(() => fetch(fileUrl))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error! Status: " + response.status);
|
||||
}
|
||||
const lenHeader =
|
||||
response.headers.get("content-length") ??
|
||||
response.headers.get("Content-Length");
|
||||
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
return Promise.all([response.text(), sizeBytes]);
|
||||
})
|
||||
.then(([content, sizeBytes]) => {
|
||||
const forcePlainText =
|
||||
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||
const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||
|
||||
// --- Build modal immediately and wire close controls BEFORE any async loads ---
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "editorContainer";
|
||||
modal.classList.add("modal", "editor-modal");
|
||||
modal.setAttribute("tabindex", "-1"); // for Escape handling
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
|
||||
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
|
||||
}</h3>
|
||||
<div class="editor-controls">
|
||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">
|
||||
${t("editing")}: ${escapeHTML(fileName)}
|
||||
${forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""}
|
||||
</h3>
|
||||
<div class="editor-controls">
|
||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||
</div>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close")}">×</button>
|
||||
</div>
|
||||
<button id="closeEditorX" class="editor-close-btn">×</button>
|
||||
</div>
|
||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||
<div class="editor-footer">
|
||||
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
|
||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||
</div>
|
||||
`;
|
||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||
<div class="editor-footer">
|
||||
<button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
|
||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
modal.focus();
|
||||
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const theme = isDarkMode ? "material-darker" : "default";
|
||||
|
||||
// choose mode + lighter settings for large files
|
||||
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||
const cmOptions = {
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: mode,
|
||||
theme: theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false,
|
||||
let canceled = false;
|
||||
const doClose = () => {
|
||||
canceled = true;
|
||||
window.currentEditor = null;
|
||||
modal.remove();
|
||||
};
|
||||
|
||||
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
||||
ensureModeLoaded(mode).finally(() => {
|
||||
const editor = CodeMirror.fromTextArea(
|
||||
// Wire close actions right away
|
||||
modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); });
|
||||
document.getElementById("closeEditorX").addEventListener("click", doClose);
|
||||
document.getElementById("closeBtn").addEventListener("click", doClose);
|
||||
|
||||
// Keep buttons responsive even before editor exists
|
||||
const decBtn = document.getElementById("decreaseFont");
|
||||
const incBtn = document.getElementById("increaseFont");
|
||||
decBtn.addEventListener("click", () => { });
|
||||
incBtn.addEventListener("click", () => { });
|
||||
|
||||
// Theme + mode selection
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const theme = isDarkMode ? "material-darker" : "default";
|
||||
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||
|
||||
// Start core+mode loading (don’t block closing)
|
||||
const modePromise = (async () => {
|
||||
await ensureCore(); // load CM core + CSS
|
||||
if (!forcePlainText) {
|
||||
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
||||
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
||||
|
||||
Promise.race([modePromise, timeout]).then(() => {
|
||||
if (canceled) return;
|
||||
|
||||
if (!window.CodeMirror) {
|
||||
// Core not present: keep plain <textarea>; enable Save and bail gracefully
|
||||
document.getElementById("saveBtn").disabled = false;
|
||||
observeModalResize(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||
|
||||
const cm = window.CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
cmOptions
|
||||
{
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: initialMode,
|
||||
theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false
|
||||
}
|
||||
);
|
||||
window.currentEditor = cm;
|
||||
|
||||
window.currentEditor = editor;
|
||||
|
||||
setTimeout(() => {
|
||||
adjustEditorSize();
|
||||
}, 50);
|
||||
|
||||
setTimeout(adjustEditorSize, 50);
|
||||
observeModalResize(modal);
|
||||
|
||||
// Font controls (now that editor exists)
|
||||
let currentFontSize = 14;
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
const wrapper = cm.getWrapperElement();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
cm.refresh();
|
||||
|
||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||
decBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
cm.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||
incBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
cm.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||
// Save
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.addEventListener("click", function () {
|
||||
saveFile(fileName, folderUsed);
|
||||
});
|
||||
|
||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Theme switch
|
||||
function updateEditorTheme() {
|
||||
const isDark = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||
}
|
||||
const toggle = document.getElementById("darkModeToggle");
|
||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||
|
||||
// If we started in plain text due to timeout, flip to the real mode once it arrives
|
||||
modePromise.then(() => {
|
||||
if (!canceled && !forcePlainText) {
|
||||
const nn = normalizeModeName(desiredMode);
|
||||
if (nn && isModeRegistered(nn)) {
|
||||
cm.setOption("mode", desiredMode);
|
||||
}
|
||||
}
|
||||
}).catch(() => { /* stay in plain text */ });
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -298,7 +708,6 @@ export function editFile(fileName, folder) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function saveFile(fileName, folder) {
|
||||
const editor = window.currentEditor;
|
||||
if (!editor) {
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
updateRowHighlight,
|
||||
toggleRowSelection,
|
||||
attachEnterKeyListener
|
||||
} from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { bindFileListContextMenu } from './fileMenu.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||
} from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
|
||||
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||
import {
|
||||
getParentFolder,
|
||||
updateBreadcrumbTitle,
|
||||
@@ -24,16 +24,35 @@ import {
|
||||
hideFolderManagerContextMenu,
|
||||
openRenameFolderModal,
|
||||
openDeleteFolderModal
|
||||
} from './folderManager.js';
|
||||
import { openFolderShareModal } from './folderShareModal.js';
|
||||
} from './folderManager.js?v={{APP_QVER}}';
|
||||
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
|
||||
import {
|
||||
folderDragOverHandler,
|
||||
folderDragLeaveHandler,
|
||||
folderDropHandler
|
||||
} from './fileDragDrop.js';
|
||||
} from './fileDragDrop.js?v={{APP_QVER}}';
|
||||
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
|
||||
|
||||
|
||||
// onnlyoffice
|
||||
let OO_ENABLED = false;
|
||||
let OO_EXTS = new Set();
|
||||
|
||||
export async function initOnlyOfficeCaps() {
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
if (!r.ok) throw 0;
|
||||
const j = await r.json();
|
||||
OO_ENABLED = !!j.enabled;
|
||||
OO_EXTS = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
} catch {
|
||||
OO_ENABLED = false;
|
||||
OO_EXTS = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Hide "Edit" for files >10 MiB
|
||||
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||
@@ -138,7 +157,121 @@ function wireSelectAll(fileListContent) {
|
||||
}
|
||||
return body ?? {};
|
||||
}
|
||||
|
||||
// ---- Viewed badges (table + gallery) ----
|
||||
// ---------- Badge factory (center text vertically) ----------
|
||||
function makeBadge(state) {
|
||||
if (!state) return null;
|
||||
const el = document.createElement('span');
|
||||
el.className = 'status-badge';
|
||||
el.style.cssText = [
|
||||
'display:inline-flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'vertical-align:middle',
|
||||
'margin-left:6px',
|
||||
'padding:2px 8px',
|
||||
'min-height:18px',
|
||||
'line-height:1',
|
||||
'border-radius:999px',
|
||||
'font-size:.78em',
|
||||
'border:1px solid rgba(0,0,0,.2)',
|
||||
'background:rgba(0,0,0,.06)'
|
||||
].join(';');
|
||||
|
||||
if (state.completed) {
|
||||
el.classList.add('watched');
|
||||
el.textContent = (t('watched') || t('viewed') || 'Watched');
|
||||
el.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
el.style.background = 'rgba(34,197,94,.15)';
|
||||
el.style.color = '#22c55e';
|
||||
return el;
|
||||
}
|
||||
|
||||
if (Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
el.classList.add('progress');
|
||||
el.textContent = `${pct}%`;
|
||||
el.style.borderColor = 'rgba(234,88,12,.55)';
|
||||
el.style.background = 'rgba(234,88,12,.18)';
|
||||
el.style.color = '#ea580c';
|
||||
return el;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Public: set/clear badges for one file (table + gallery) ----------
|
||||
function applyBadgeToDom(name, state) {
|
||||
const safe = CSS.escape(name);
|
||||
|
||||
// Table
|
||||
document.querySelectorAll(`tr[data-file-name="${safe}"] .name-cell, tr[data-file-name="${safe}"] .file-name-cell`)
|
||||
.forEach(cell => {
|
||||
cell.querySelector('.status-badge')?.remove();
|
||||
const b = makeBadge(state);
|
||||
if (b) cell.appendChild(b);
|
||||
});
|
||||
|
||||
// Gallery
|
||||
document.querySelectorAll(`.gallery-card[data-file-name="${safe}"] .gallery-file-name`)
|
||||
.forEach(title => {
|
||||
title.querySelector('.status-badge')?.remove();
|
||||
const b = makeBadge(state);
|
||||
if (b) title.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function setFileWatchedBadge(name, watched = true) {
|
||||
applyBadgeToDom(name, watched ? { completed: true } : null);
|
||||
}
|
||||
|
||||
export function setFileProgressBadge(name, seconds, duration) {
|
||||
if (duration > 0 && seconds >= 0) {
|
||||
applyBadgeToDom(name, { seconds, duration, completed: seconds >= duration - 1 });
|
||||
} else {
|
||||
applyBadgeToDom(name, null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshViewedBadges(folder) {
|
||||
let map = null;
|
||||
try {
|
||||
const res = await fetch(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`, { credentials: 'include' });
|
||||
const j = await res.json();
|
||||
map = j?.map || null;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear any existing badges
|
||||
document.querySelectorAll(
|
||||
'#fileList tr[data-file-name] .file-name-cell .status-badge, ' +
|
||||
'#fileList tr[data-file-name] .name-cell .status-badge, ' +
|
||||
'.gallery-card[data-file-name] .gallery-file-name .status-badge'
|
||||
).forEach(n => n.remove());
|
||||
|
||||
if (!map) return;
|
||||
|
||||
// Table rows
|
||||
document.querySelectorAll('#fileList tr[data-file-name]').forEach(tr => {
|
||||
const name = tr.getAttribute('data-file-name');
|
||||
const state = map[name];
|
||||
if (!state) return;
|
||||
const cell = tr.querySelector('.name-cell, .file-name-cell');
|
||||
if (!cell) return;
|
||||
const badge = makeBadge(state);
|
||||
if (badge) cell.appendChild(badge);
|
||||
});
|
||||
|
||||
// Gallery cards
|
||||
document.querySelectorAll('.gallery-card[data-file-name]').forEach(card => {
|
||||
const name = card.getAttribute('data-file-name');
|
||||
const state = map[name];
|
||||
if (!state) return;
|
||||
const title = card.querySelector('.gallery-file-name');
|
||||
if (!title) return;
|
||||
const badge = makeBadge(state);
|
||||
if (badge) title.appendChild(badge);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||
*/
|
||||
@@ -205,29 +338,84 @@ function wireSelectAll(fileListContent) {
|
||||
/**
|
||||
* Fuse.js fuzzy search helper
|
||||
*/
|
||||
function searchFiles(searchTerm) {
|
||||
if (!searchTerm) return fileData;
|
||||
|
||||
let keys = [
|
||||
{ name: 'name', weight: 0.1 },
|
||||
{ name: 'uploader', weight: 0.1 },
|
||||
{ name: 'tags.name', weight: 0.1 }
|
||||
];
|
||||
if (window.advancedSearchEnabled) {
|
||||
keys.push({ name: 'content', weight: 0.7 });
|
||||
}
|
||||
|
||||
// --- Lazy Fuse loader (drop-in, CSP-safe, no inline) ---
|
||||
const FUSE_SRC = '/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}';
|
||||
let _fuseLoadingPromise = null;
|
||||
|
||||
function loadScriptOnce(src) {
|
||||
// cache by src so we don't append multiple <script> tags
|
||||
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
|
||||
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
|
||||
const p = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
loadScriptOnce._cache.set(src, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function lazyLoadFuse() {
|
||||
if (window.Fuse) return Promise.resolve(window.Fuse);
|
||||
if (!_fuseLoadingPromise) {
|
||||
_fuseLoadingPromise = loadScriptOnce(FUSE_SRC).then(() => window.Fuse);
|
||||
}
|
||||
return _fuseLoadingPromise;
|
||||
}
|
||||
|
||||
// (Optional) warm-up call you can trigger from main.js after first render:
|
||||
// import { warmUpSearch } from './fileListView.js?v={{APP_QVER}}';
|
||||
// warmUpSearch();
|
||||
// This just starts fetching Fuse in the background.
|
||||
export function warmUpSearch() {
|
||||
lazyLoadFuse().catch(() => {/* ignore; we’ll fall back */});
|
||||
}
|
||||
|
||||
// Lazy + backward-compatible search
|
||||
function searchFiles(searchTerm) {
|
||||
if (!searchTerm) return fileData;
|
||||
|
||||
// kick off Fuse load in the background, but don't await
|
||||
lazyLoadFuse().catch(() => { /* ignore */ });
|
||||
|
||||
// keys config (matches your original)
|
||||
const fuseKeys = [
|
||||
{ name: 'name', weight: 0.1 },
|
||||
{ name: 'uploader', weight: 0.1 },
|
||||
{ name: 'tags.name', weight: 0.1 }
|
||||
];
|
||||
if (window.advancedSearchEnabled) {
|
||||
fuseKeys.push({ name: 'content', weight: 0.7 });
|
||||
}
|
||||
|
||||
// If Fuse is present, use it right away (synchronous API)
|
||||
if (window.Fuse) {
|
||||
const options = {
|
||||
keys: keys,
|
||||
keys: fuseKeys,
|
||||
threshold: 0.4,
|
||||
minMatchCharLength: 2,
|
||||
ignoreLocation: true
|
||||
};
|
||||
|
||||
const fuse = new Fuse(fileData, options);
|
||||
let results = fuse.search(searchTerm);
|
||||
return results.map(result => result.item);
|
||||
const fuse = new window.Fuse(fileData, options);
|
||||
const results = fuse.search(searchTerm);
|
||||
return results.map(r => r.item);
|
||||
}
|
||||
|
||||
// Fallback (first keystrokes before Fuse finishes loading):
|
||||
// simple case-insensitive substring match on the same fields
|
||||
const q = String(searchTerm).toLowerCase();
|
||||
const hay = (v) => (v == null ? '' : String(v)).toLowerCase();
|
||||
return fileData.filter(item => {
|
||||
if (hay(item.name).includes(q)) return true;
|
||||
if (hay(item.uploader).includes(q)) return true;
|
||||
if (Array.isArray(item.tags) && item.tags.some(t => hay(t?.name).includes(q))) return true;
|
||||
if (window.advancedSearchEnabled && hay(item.content).includes(q)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode toggle
|
||||
@@ -283,6 +471,7 @@ function wireSelectAll(fileListContent) {
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
export async function loadFileList(folderParam) {
|
||||
await initOnlyOfficeCaps();
|
||||
const reqId = ++__fileListReqSeq; // latest call wins
|
||||
const folder = folderParam || "root";
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
@@ -473,6 +662,7 @@ function wireSelectAll(fileListContent) {
|
||||
}
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
|
||||
|
||||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||
try {
|
||||
@@ -637,9 +827,14 @@ function wireSelectAll(fileListContent) {
|
||||
if (totalFiles > 0) {
|
||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||
// Build row with a neutral base, then correct the links/preview below.
|
||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||
// Give the row an ID so we can patch attributes safely
|
||||
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
|
||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||
|
||||
// add row id + data-file-name, and ensure the name cell also has "name-cell"
|
||||
rowHTML = rowHTML
|
||||
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
|
||||
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
|
||||
|
||||
let tagBadgesHTML = "";
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
@@ -649,9 +844,13 @@ function wireSelectAll(fileListContent) {
|
||||
});
|
||||
tagBadgesHTML += "</div>";
|
||||
}
|
||||
rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||
return p1 + p2 + tagBadgesHTML + p3;
|
||||
});
|
||||
rowsHTML += rowHTML.replace(
|
||||
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
|
||||
(m, open, inner, close) => {
|
||||
// keep the original filename content, then add your tag badges, then close
|
||||
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||
@@ -750,7 +949,7 @@ function wireSelectAll(fileListContent) {
|
||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async e => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -759,7 +958,7 @@ function wireSelectAll(fileListContent) {
|
||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async e => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -768,7 +967,7 @@ function wireSelectAll(fileListContent) {
|
||||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async e => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -822,16 +1021,17 @@ function wireSelectAll(fileListContent) {
|
||||
const fileName = this.getAttribute("data-file");
|
||||
const file = fileData.find(f => f.name === fileName);
|
||||
if (file) {
|
||||
import('./filePreview.js').then(module => {
|
||||
import('./filePreview.js?v={{APP_QVER}}').then(module => {
|
||||
module.openShareModal(file, folder);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
updateFileActionButtons();
|
||||
|
||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||
row.setAttribute("draggable", "true");
|
||||
import('./fileDragDrop.js').then(module => {
|
||||
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||
row.addEventListener("dragstart", module.fileDragStartHandler);
|
||||
});
|
||||
});
|
||||
@@ -839,6 +1039,7 @@ function wireSelectAll(fileListContent) {
|
||||
btn.addEventListener("click", e => e.stopPropagation());
|
||||
});
|
||||
bindFileListContextMenu();
|
||||
refreshViewedBadges(folder).catch(() => {});
|
||||
}
|
||||
|
||||
// A helper to compute the max image height based on the current column count.
|
||||
@@ -965,6 +1166,7 @@ function wireSelectAll(fileListContent) {
|
||||
// card with checkbox, preview, info, buttons
|
||||
galleryHTML += `
|
||||
<div class="gallery-card"
|
||||
data-file-name="${escapeHTML(file.name)}"
|
||||
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
||||
<input type="checkbox"
|
||||
class="file-checkbox"
|
||||
@@ -1085,7 +1287,7 @@ function wireSelectAll(fileListContent) {
|
||||
// preview clicks (dynamic import to avoid global dependency)
|
||||
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1102,7 +1304,7 @@ function wireSelectAll(fileListContent) {
|
||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async e => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1111,7 +1313,7 @@ function wireSelectAll(fileListContent) {
|
||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async e => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1123,7 +1325,7 @@ function wireSelectAll(fileListContent) {
|
||||
const fileName = btn.dataset.file;
|
||||
const fileObj = fileData.find(f => f.name === fileName);
|
||||
if (fileObj) {
|
||||
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
|
||||
import('./filePreview.js?v={{APP_QVER}}').then(m => m.openShareModal(fileObj, folder));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1161,7 +1363,7 @@ function wireSelectAll(fileListContent) {
|
||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||
else renderFileTable(folder);
|
||||
};
|
||||
|
||||
refreshViewedBadges(folder).catch(() => {});
|
||||
updateFileActionButtons();
|
||||
createViewToggleButton();
|
||||
}
|
||||
@@ -1273,46 +1475,34 @@ function wireSelectAll(fileListContent) {
|
||||
if (!fileName || typeof fileName !== "string") return false;
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
if (dot < 0) return false;
|
||||
|
||||
const ext = fileName.slice(dot + 1).toLowerCase();
|
||||
|
||||
const allowedExtensions = [
|
||||
"txt", "text", "md", "markdown", "rst",
|
||||
"html", "htm", "xhtml", "shtml",
|
||||
"css", "scss", "sass", "less",
|
||||
"js", "mjs", "cjs", "jsx",
|
||||
"ts", "tsx",
|
||||
"json", "jsonc", "ndjson",
|
||||
"yml", "yaml", "toml", "xml", "plist",
|
||||
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||||
"env", "dotenv",
|
||||
"csv", "tsv", "tab",
|
||||
// Your CodeMirror text-based types
|
||||
const textEditExts = new Set([
|
||||
"txt","text","md","markdown","rst",
|
||||
"html","htm","xhtml","shtml",
|
||||
"css","scss","sass","less",
|
||||
"js","mjs","cjs","jsx",
|
||||
"ts","tsx",
|
||||
"json","jsonc","ndjson",
|
||||
"yml","yaml","toml","xml","plist",
|
||||
"ini","conf","config","cfg","cnf","properties","props","rc",
|
||||
"env","dotenv",
|
||||
"csv","tsv","tab",
|
||||
"log",
|
||||
"sh", "bash", "zsh", "ksh", "fish",
|
||||
"bat", "cmd",
|
||||
"ps1", "psm1", "psd1",
|
||||
"py", "pyw",
|
||||
"rb",
|
||||
"pl", "pm",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"kt", "kts",
|
||||
"scala", "sc",
|
||||
"groovy", "gradle",
|
||||
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
|
||||
"m", "mm",
|
||||
"swift",
|
||||
"cs", "fs", "fsx",
|
||||
"dart",
|
||||
"lua",
|
||||
"r", "rmd",
|
||||
"sql",
|
||||
"vue", "svelte",
|
||||
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||||
];
|
||||
"sh","bash","zsh","ksh","fish",
|
||||
"bat","cmd",
|
||||
"ps1","psm1","psd1",
|
||||
"py","pyw","rb","pl","pm","go","rs","java","kt","kts",
|
||||
"scala","sc","groovy","gradle",
|
||||
"c","h","cpp","cxx","cc","hpp","hh","hxx",
|
||||
"m","mm","swift","cs","fs","fsx","dart","lua","r","rmd",
|
||||
"sql","vue","svelte","twig","mustache","hbs","handlebars","ejs","pug","jade"
|
||||
]);
|
||||
|
||||
return allowedExtensions.includes(ext);
|
||||
if (textEditExts.has(ext)) return true; // CodeMirror
|
||||
if (OO_ENABLED && OO_EXTS.has(ext)) return true; // ONLYOFFICE types if enabled
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expose global functions for pagination and preview.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// fileManager.js
|
||||
import './fileListView.js';
|
||||
import './filePreview.js';
|
||||
import './fileEditor.js';
|
||||
import './fileDragDrop.js';
|
||||
import './fileMenu.js';
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import './fileListView.js?v={{APP_QVER}}';
|
||||
import './filePreview.js?v={{APP_QVER}}';
|
||||
import './fileEditor.js?v={{APP_QVER}}';
|
||||
import './fileDragDrop.js?v={{APP_QVER}}';
|
||||
import './fileMenu.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
|
||||
// Initialize file action buttons.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
// Attach folder drag-and-drop support for folder tree nodes.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
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("dragleave", module.folderDragLeaveHandler);
|
||||
el.addEventListener("drop", module.folderDropHandler);
|
||||
@@ -32,7 +32,7 @@ document.addEventListener("keydown", function(e) {
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 0) {
|
||||
e.preventDefault();
|
||||
import('./fileActions.js').then(module => {
|
||||
import('./fileActions.js?v={{APP_QVER}}').then(module => {
|
||||
module.handleDeleteSelected(new Event("click"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// fileMenu.js
|
||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
|
||||
import { previewFile } from './filePreview.js';
|
||||
import { editFile } from './fileEditor.js';
|
||||
import { canEditFile, fileData } from './fileListView.js';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||
import { t } from './i18n.js';
|
||||
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function showFileContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("fileContextMenu");
|
||||
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
|
||||
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
if (menuRect.bottom > viewportHeight) {
|
||||
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
|
||||
|
||||
export function fileListContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
let row = e.target.closest("tr");
|
||||
if (row) {
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
|
||||
updateRowHighlight(checkbox);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||
|
||||
|
||||
let menuItems = [
|
||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
|
||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
|
||||
];
|
||||
|
||||
|
||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
||||
menuItems.push({
|
||||
label: t("extract_zip"),
|
||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (selected.length > 1) {
|
||||
menuItems.push({
|
||||
label: t("tag_selected"),
|
||||
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
|
||||
}
|
||||
else if (selected.length === 1) {
|
||||
const file = fileData.find(f => f.name === selected[0]);
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("preview"),
|
||||
action: () => {
|
||||
const folder = window.currentFolder || "root";
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
|
||||
previewFile(buildPreviewUrl(folder, file.name), file.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (canEditFile(file.name)) {
|
||||
menuItems.push({
|
||||
label: t("edit"),
|
||||
action: () => { editFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("rename"),
|
||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("tag_file"),
|
||||
action: () => { openTagModal(file); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
||||
}
|
||||
|
||||
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
document.addEventListener("click", function (e) {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu && menu.style.display === "block") {
|
||||
hideFileContextMenu();
|
||||
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
|
||||
});
|
||||
|
||||
// Rebind context menu after file table render.
|
||||
(function() {
|
||||
(function () {
|
||||
const originalRenderFileTable = window.renderFileTable;
|
||||
window.renderFileTable = function(folder) {
|
||||
window.renderFileTable = function (folder) {
|
||||
originalRenderFileTable(folder);
|
||||
bindFileListContextMenu();
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// filePreview.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { fileData } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
|
||||
export function buildPreviewUrl(folder, name) {
|
||||
const f = (!folder || folder === '') ? 'root' : String(folder);
|
||||
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||
export function openShareModal(file, folder) {
|
||||
// Remove any existing modal
|
||||
const existing = document.getElementById("shareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Build the modal
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "shareModal";
|
||||
modal.classList.add("modal");
|
||||
@@ -45,18 +50,9 @@ export function openShareModal(file, folder) {
|
||||
</div>
|
||||
|
||||
<p style="margin-top:15px;">${t("password_optional")}</p>
|
||||
<input
|
||||
type="text"
|
||||
id="sharePassword"
|
||||
placeholder="${t("password_optional")}"
|
||||
style="width:100%;padding:5px;"
|
||||
/>
|
||||
<input type="text" id="sharePassword" placeholder="${t("password_optional")}" style="width:100%;padding:5px;"/>
|
||||
|
||||
<button
|
||||
id="generateShareLinkBtn"
|
||||
class="btn btn-primary"
|
||||
style="margin-top:15px;"
|
||||
>
|
||||
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:15px;">
|
||||
${t("generate_share_link")}
|
||||
</button>
|
||||
|
||||
@@ -73,48 +69,32 @@ export function openShareModal(file, folder) {
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
// Close handler
|
||||
document.getElementById("closeShareModal")
|
||||
.addEventListener("click", () => modal.remove());
|
||||
document.getElementById("closeShareModal").addEventListener("click", () => modal.remove());
|
||||
document.getElementById("shareExpiration").addEventListener("change", e => {
|
||||
const container = document.getElementById("customExpirationContainer");
|
||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
|
||||
// Show/hide custom-duration inputs
|
||||
document.getElementById("shareExpiration")
|
||||
.addEventListener("change", e => {
|
||||
const container = document.getElementById("customExpirationContainer");
|
||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
|
||||
const sel = document.getElementById("shareExpiration");
|
||||
let value, unit;
|
||||
|
||||
// Generate share link
|
||||
document.getElementById("generateShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const sel = document.getElementById("shareExpiration");
|
||||
let value, unit;
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
file: file.name,
|
||||
expirationValue: value,
|
||||
expirationUnit: unit,
|
||||
password
|
||||
})
|
||||
})
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
@@ -122,349 +102,562 @@ export function openShareModal(file, folder) {
|
||||
document.getElementById("shareLinkInput").value = url;
|
||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||
} else {
|
||||
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
|
||||
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
document.getElementById("copyShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
}
|
||||
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
let modal = document.getElementById("filePreviewModal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "filePreviewModal";
|
||||
Object.assign(modal.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: "1000"
|
||||
});
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
|
||||
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">×</span>
|
||||
<h4 class="image-modal-header"></h4>
|
||||
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
|
||||
const TXT_RE = /\.(txt|rtf|md|log)$/i;
|
||||
|
||||
function closeModal() {
|
||||
const mediaElements = modal.querySelectorAll("video, audio");
|
||||
mediaElements.forEach(media => {
|
||||
media.pause();
|
||||
if (media.tagName.toLowerCase() !== 'video') {
|
||||
try { media.currentTime = 0; } catch (e) { }
|
||||
}
|
||||
});
|
||||
modal.remove();
|
||||
}
|
||||
function getIconForFile(name) {
|
||||
const lower = (name || '').toLowerCase();
|
||||
if (IMG_RE.test(lower)) return 'image';
|
||||
if (VID_RE.test(lower)) return 'ondemand_video';
|
||||
if (AUD_RE.test(lower)) return 'audiotrack';
|
||||
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
|
||||
if (ARCH_RE.test(lower)) return 'archive';
|
||||
if (CODE_RE.test(lower)) return 'code';
|
||||
if (TXT_RE.test(lower)) return 'description';
|
||||
return 'insert_drive_file';
|
||||
}
|
||||
|
||||
document.getElementById("closeFileModal").addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
function ensureMediaModal() {
|
||||
let overlay = document.getElementById("filePreviewModal");
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "filePreviewModal";
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: "1000"
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
const styles = getComputedStyle(root);
|
||||
const isDark = root.classList.contains('dark-mode');
|
||||
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
|
||||
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
|
||||
|
||||
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
|
||||
const navFg = '#fff';
|
||||
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||
|
||||
// fixed top bar; pad-right to avoid overlap with absolute close “×”
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content media-modal" style="
|
||||
position: relative;
|
||||
max-width: 92vw;
|
||||
width: 92vw;
|
||||
max-height: 92vh;
|
||||
height: 92vh;
|
||||
box-sizing: border-box;
|
||||
background: ${panelBg};
|
||||
color: ${textCol};
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
display:flex; flex-direction:column;
|
||||
">
|
||||
<!-- Top bar -->
|
||||
<div class="media-topbar" style="
|
||||
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
|
||||
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
|
||||
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
|
||||
background:${panelBg};
|
||||
">
|
||||
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||
<span class="material-icons title-icon" style="
|
||||
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
|
||||
insert_drive_file
|
||||
</span>
|
||||
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||||
</div>
|
||||
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
|
||||
<span class="status-chip" style="
|
||||
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
||||
border:1px solid transparent; background:transparent; color:inherit;"></span>
|
||||
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage -->
|
||||
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||
|
||||
<!-- prev/next = rounded rectangles with centered glyphs -->
|
||||
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
||||
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
||||
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
||||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
||||
</div>
|
||||
|
||||
<!-- Absolute close “×” (like original), themed + hover behavior -->
|
||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
|
||||
position:absolute; top:8px; right:10px; z-index:1002;
|
||||
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
|
||||
">×</span>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// theme the close “×” for visibility + hover rules that match your site:
|
||||
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||
function paintCloseBase() {
|
||||
closeBtn.style.backgroundColor = 'transparent';
|
||||
closeBtn.style.color = '#e11d48'; // base red X
|
||||
closeBtn.style.boxShadow = 'none';
|
||||
}
|
||||
modal.querySelector("h4").textContent = fileName;
|
||||
const container = modal.querySelector(".file-preview-container");
|
||||
container.innerHTML = "";
|
||||
function onCloseHoverEnter() {
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
closeBtn.style.backgroundColor = '#ef4444'; // red fill
|
||||
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
|
||||
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
|
||||
}
|
||||
function onCloseHoverLeave() { paintCloseBase(); }
|
||||
paintCloseBase();
|
||||
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
|
||||
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
|
||||
|
||||
const extension = fileName.split('.').pop().toLowerCase();
|
||||
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName);
|
||||
function closeModal() {
|
||||
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay.remove();
|
||||
}
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setTitle(overlay, name) {
|
||||
const textEl = overlay.querySelector('.title-text');
|
||||
const iconEl = overlay.querySelector('.title-icon');
|
||||
if (textEl) {
|
||||
textEl.textContent = name || '';
|
||||
textEl.setAttribute('title', name || '');
|
||||
}
|
||||
if (iconEl) {
|
||||
iconEl.textContent = getIconForFile(name);
|
||||
// keep the icon legible in both themes
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
||||
}
|
||||
}
|
||||
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
function makeTopIcon(name, title) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'material-icons';
|
||||
b.textContent = name;
|
||||
b.title = title;
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
|
||||
Object.assign(b.style, {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
|
||||
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
lineHeight: '1',
|
||||
color: dark ? '#f5f5f5' : '#111',
|
||||
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
|
||||
});
|
||||
|
||||
b.addEventListener('mouseenter', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
|
||||
});
|
||||
b.addEventListener('mouseleave', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
|
||||
});
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
function setNavVisibility(overlay, showPrev, showNext) {
|
||||
const prev = overlay.querySelector('.nav-left');
|
||||
const next = overlay.querySelector('.nav-right');
|
||||
prev.style.display = showPrev ? 'flex' : 'none';
|
||||
next.style.display = showNext ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function setRowWatchedBadge(name, watched) {
|
||||
try {
|
||||
const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`);
|
||||
if (!cell) return;
|
||||
const old = cell.querySelector('.status-badge.watched');
|
||||
if (watched) {
|
||||
if (!old) {
|
||||
const b = document.createElement('span');
|
||||
b.className = 'status-badge watched';
|
||||
b.textContent = t("watched") || t("viewed") || "Watched";
|
||||
b.style.marginLeft = "6px";
|
||||
cell.appendChild(b);
|
||||
}
|
||||
} else if (old) {
|
||||
old.remove();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* -------------------------------- Entry -------------------------------- */
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
const overlay = ensureMediaModal();
|
||||
const container = overlay.querySelector(".file-preview-container");
|
||||
const actionWrap = overlay.querySelector(".media-right .action-group");
|
||||
const statusChip = overlay.querySelector(".media-right .status-chip");
|
||||
|
||||
// replace nav buttons to clear old listeners
|
||||
let prevBtn = overlay.querySelector('.nav-left');
|
||||
let nextBtn = overlay.querySelector('.nav-right');
|
||||
const newPrev = prevBtn.cloneNode(true);
|
||||
const newNext = nextBtn.cloneNode(true);
|
||||
prevBtn.replaceWith(newPrev);
|
||||
nextBtn.replaceWith(newNext);
|
||||
prevBtn = newPrev; nextBtn = newNext;
|
||||
|
||||
// reset
|
||||
container.innerHTML = "";
|
||||
actionWrap.innerHTML = "";
|
||||
if (statusChip) statusChip.style.display = 'none';
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay._onKey = null;
|
||||
|
||||
const folder = window.currentFolder || 'root';
|
||||
const name = fileName;
|
||||
const lower = (name || '').toLowerCase();
|
||||
const isImage = IMG_RE.test(lower);
|
||||
const isVideo = VID_RE.test(lower);
|
||||
const isAudio = AUD_RE.test(lower);
|
||||
|
||||
setTitle(overlay, name);
|
||||
|
||||
/* -------------------- IMAGES -------------------- */
|
||||
if (isImage) {
|
||||
// Create the image element with default transform data.
|
||||
const img = document.createElement("img");
|
||||
img.src = fileUrl;
|
||||
img.className = "image-modal-img";
|
||||
img.style.maxWidth = "80vw";
|
||||
img.style.maxHeight = "80vh";
|
||||
img.style.maxWidth = "88vw";
|
||||
img.style.maxHeight = "88vh";
|
||||
img.style.transition = "transform 0.3s ease";
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.position = 'relative';
|
||||
img.style.zIndex = '1';
|
||||
container.appendChild(img);
|
||||
|
||||
// Filter gallery images for navigation.
|
||||
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name));
|
||||
// topbar-aligned, theme-aware icons
|
||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
actionWrap.appendChild(zoomInBtn);
|
||||
actionWrap.appendChild(zoomOutBtn);
|
||||
actionWrap.appendChild(rotateLeft);
|
||||
actionWrap.appendChild(rotateRight);
|
||||
|
||||
// Create a flex wrapper to hold left panel, center image, and right panel.
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'image-wrapper';
|
||||
wrapper.style.display = 'flex';
|
||||
wrapper.style.alignItems = 'center';
|
||||
wrapper.style.justifyContent = 'center';
|
||||
wrapper.style.position = 'relative';
|
||||
zoomInBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let s = parseFloat(img.dataset.scale) || 1; s += 0.1;
|
||||
img.dataset.scale = s;
|
||||
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||
});
|
||||
zoomOutBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1);
|
||||
img.dataset.scale = s;
|
||||
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||
});
|
||||
rotateLeft.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360;
|
||||
img.dataset.rotate = r;
|
||||
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||
});
|
||||
rotateRight.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360;
|
||||
img.dataset.rotate = r;
|
||||
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||
});
|
||||
|
||||
// --- Left Panel: Contains Zoom controls (top) and Prev button (bottom) ---
|
||||
const leftPanel = document.createElement('div');
|
||||
leftPanel.className = 'left-panel';
|
||||
leftPanel.style.display = 'flex';
|
||||
leftPanel.style.flexDirection = 'column';
|
||||
leftPanel.style.justifyContent = 'space-between';
|
||||
leftPanel.style.alignItems = 'center';
|
||||
leftPanel.style.width = '60px';
|
||||
leftPanel.style.height = '100%';
|
||||
leftPanel.style.zIndex = '10';
|
||||
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
|
||||
// Top container for zoom buttons.
|
||||
const leftTop = document.createElement('div');
|
||||
leftTop.style.display = 'flex';
|
||||
leftTop.style.flexDirection = 'column';
|
||||
leftTop.style.gap = '4px';
|
||||
// Zoom In button.
|
||||
const zoomInBtn = document.createElement('button');
|
||||
zoomInBtn.className = 'material-icons zoom_in';
|
||||
zoomInBtn.title = 'Zoom In';
|
||||
zoomInBtn.style.background = 'transparent';
|
||||
zoomInBtn.style.border = 'none';
|
||||
zoomInBtn.style.cursor = 'pointer';
|
||||
zoomInBtn.textContent = 'zoom_in';
|
||||
// Zoom Out button.
|
||||
const zoomOutBtn = document.createElement('button');
|
||||
zoomOutBtn.className = 'material-icons zoom_out';
|
||||
zoomOutBtn.title = 'Zoom Out';
|
||||
zoomOutBtn.style.background = 'transparent';
|
||||
zoomOutBtn.style.border = 'none';
|
||||
zoomOutBtn.style.cursor = 'pointer';
|
||||
zoomOutBtn.textContent = 'zoom_out';
|
||||
leftTop.appendChild(zoomInBtn);
|
||||
leftTop.appendChild(zoomOutBtn);
|
||||
leftPanel.appendChild(leftTop);
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = buildPreviewUrl(folder, newFile);
|
||||
};
|
||||
|
||||
// Bottom container for prev button.
|
||||
const leftBottom = document.createElement('div');
|
||||
leftBottom.style.display = 'flex';
|
||||
leftBottom.style.justifyContent = 'center';
|
||||
leftBottom.style.alignItems = 'center';
|
||||
leftBottom.style.width = '100%';
|
||||
if (images.length > 1) {
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.textContent = "‹";
|
||||
prevBtn.className = "gallery-nav-btn";
|
||||
prevBtn.style.background = 'transparent';
|
||||
prevBtn.style.border = 'none';
|
||||
prevBtn.style.color = 'white';
|
||||
prevBtn.style.fontSize = '48px';
|
||||
prevBtn.style.cursor = 'pointer';
|
||||
prevBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
// Safety check:
|
||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
});
|
||||
leftBottom.appendChild(prevBtn);
|
||||
} else {
|
||||
// Insert an empty placeholder for consistent layout.
|
||||
leftBottom.innerHTML = ' ';
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
leftPanel.appendChild(leftBottom);
|
||||
|
||||
// --- Center Panel: Contains the image ---
|
||||
const centerPanel = document.createElement('div');
|
||||
centerPanel.className = 'center-image-container';
|
||||
centerPanel.style.flexGrow = '1';
|
||||
centerPanel.style.textAlign = 'center';
|
||||
centerPanel.style.position = 'relative';
|
||||
centerPanel.style.zIndex = '1';
|
||||
centerPanel.appendChild(img);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Right Panel: Contains Rotate controls (top) and Next button (bottom) ---
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'right-panel';
|
||||
rightPanel.style.display = 'flex';
|
||||
rightPanel.style.flexDirection = 'column';
|
||||
rightPanel.style.justifyContent = 'space-between';
|
||||
rightPanel.style.alignItems = 'center';
|
||||
rightPanel.style.width = '60px';
|
||||
rightPanel.style.height = '100%';
|
||||
rightPanel.style.zIndex = '10';
|
||||
|
||||
// Top container for rotate buttons.
|
||||
const rightTop = document.createElement('div');
|
||||
rightTop.style.display = 'flex';
|
||||
rightTop.style.flexDirection = 'column';
|
||||
rightTop.style.gap = '4px';
|
||||
// Rotate Left button.
|
||||
const rotateLeftBtn = document.createElement('button');
|
||||
rotateLeftBtn.className = 'material-icons rotate_left';
|
||||
rotateLeftBtn.title = 'Rotate Left';
|
||||
rotateLeftBtn.style.background = 'transparent';
|
||||
rotateLeftBtn.style.border = 'none';
|
||||
rotateLeftBtn.style.cursor = 'pointer';
|
||||
rotateLeftBtn.textContent = 'rotate_left';
|
||||
// Rotate Right button.
|
||||
const rotateRightBtn = document.createElement('button');
|
||||
rotateRightBtn.className = 'material-icons rotate_right';
|
||||
rotateRightBtn.title = 'Rotate Right';
|
||||
rotateRightBtn.style.background = 'transparent';
|
||||
rotateRightBtn.style.border = 'none';
|
||||
rotateRightBtn.style.cursor = 'pointer';
|
||||
rotateRightBtn.textContent = 'rotate_right';
|
||||
rightTop.appendChild(rotateLeftBtn);
|
||||
rightTop.appendChild(rotateRightBtn);
|
||||
rightPanel.appendChild(rightTop);
|
||||
|
||||
// Bottom container for next button.
|
||||
const rightBottom = document.createElement('div');
|
||||
rightBottom.style.display = 'flex';
|
||||
rightBottom.style.justifyContent = 'center';
|
||||
rightBottom.style.alignItems = 'center';
|
||||
rightBottom.style.width = '100%';
|
||||
if (images.length > 1) {
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.textContent = "›";
|
||||
nextBtn.className = "gallery-nav-btn";
|
||||
nextBtn.style.background = 'transparent';
|
||||
nextBtn.style.border = 'none';
|
||||
nextBtn.style.color = 'white';
|
||||
nextBtn.style.fontSize = '48px';
|
||||
nextBtn.style.cursor = 'pointer';
|
||||
nextBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
// Safety check:
|
||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
});
|
||||
rightBottom.appendChild(nextBtn);
|
||||
} else {
|
||||
// Insert a placeholder so that center remains properly aligned.
|
||||
rightBottom.innerHTML = ' ';
|
||||
}
|
||||
rightPanel.appendChild(rightBottom);
|
||||
|
||||
// Assemble panels into the wrapper.
|
||||
wrapper.appendChild(leftPanel);
|
||||
wrapper.appendChild(centerPanel);
|
||||
wrapper.appendChild(rightPanel);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// --- Set up zoom controls event listeners ---
|
||||
zoomInBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let scale = parseFloat(img.dataset.scale) || 1;
|
||||
scale += 0.1;
|
||||
img.dataset.scale = scale;
|
||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
||||
});
|
||||
zoomOutBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let scale = parseFloat(img.dataset.scale) || 1;
|
||||
scale = Math.max(0.1, scale - 0.1);
|
||||
img.dataset.scale = scale;
|
||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
||||
});
|
||||
|
||||
// Attach rotation control listeners (always present now).
|
||||
rotateLeftBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
||||
rotate = (rotate - 90 + 360) % 360;
|
||||
img.dataset.rotate = rotate;
|
||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
||||
});
|
||||
rotateRightBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
||||
rotate = (rotate + 90) % 360;
|
||||
img.dataset.rotate = rotate;
|
||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
||||
});
|
||||
|
||||
// Save gallery details if there is more than one image.
|
||||
if (images.length > 1) {
|
||||
modal.galleryImages = images;
|
||||
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
|
||||
}
|
||||
} else {
|
||||
// Handle non-image file previews.
|
||||
if (extension === "pdf") {
|
||||
// build a cache‐busted URL
|
||||
/* -------------------- PDF => new tab -------------------- */
|
||||
if (lower.endsWith('.pdf')) {
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||
|
||||
// open in a new tab (avoids CSP frame-ancestors)
|
||||
window.open(urlWithTs, "_blank");
|
||||
|
||||
// tear down the just-created modal
|
||||
const modal = document.getElementById("filePreviewModal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
// stop further preview logic
|
||||
overlay.remove();
|
||||
return;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = fileUrl;
|
||||
video.controls = true;
|
||||
video.className = "image-modal-img";
|
||||
|
||||
const progressKey = 'videoProgress-' + fileUrl;
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const savedTime = localStorage.getItem(progressKey);
|
||||
if (savedTime) {
|
||||
video.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video"); // let so we can rebind
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) { pending = false; console.error(e); return null; }
|
||||
}
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
const nv = video.cloneNode(true);
|
||||
video.replaceWith(nv);
|
||||
video = nv;
|
||||
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
video.addEventListener("timeupdate", () => {
|
||||
localStorage.setItem(progressKey, video.currentTime);
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
video.addEventListener("ended", () => {
|
||||
localStorage.removeItem(progressKey);
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
container.appendChild(video);
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = fileUrl;
|
||||
audio.controls = true;
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "80vw";
|
||||
container.appendChild(audio);
|
||||
} else {
|
||||
container.textContent = "Preview not available for this file type.";
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
clearBtnIcon.onclick = async () => {
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
}
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
bindVideoEvents(nm);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
bindVideoEvents(name);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = fileUrl;
|
||||
audio.controls = true;
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "88vw";
|
||||
container.appendChild(audio);
|
||||
overlay.style.display = "flex";
|
||||
} else {
|
||||
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||
overlay.style.display = "flex";
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
// Preserve original functionality.
|
||||
/* -------------------------------- Small display helper -------------------------------- */
|
||||
export function displayFilePreview(file, container) {
|
||||
const actualFile = file.file || file;
|
||||
if (!(actualFile instanceof File)) {
|
||||
@@ -472,10 +665,9 @@ export function displayFilePreview(file, container) {
|
||||
return;
|
||||
}
|
||||
container.style.display = "inline-block";
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
if (IMG_RE.test(actualFile.name)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(actualFile);
|
||||
img.classList.add("file-preview-img");
|
||||
@@ -488,5 +680,6 @@ export function displayFilePreview(file, container) {
|
||||
}
|
||||
}
|
||||
|
||||
// expose for HTML onclick usage
|
||||
window.previewFile = previewFile;
|
||||
window.openShareModal = openShareModal;
|
||||
@@ -3,9 +3,9 @@
|
||||
// adding tags to files (with a global tag store for reuse),
|
||||
// updating the file row display with tag badges,
|
||||
// filtering the file list by tag, and persisting tag data.
|
||||
import { escapeHTML } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { renderFileTable, renderGalleryView } from './fileListView.js';
|
||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
export function openTagModal(file) {
|
||||
// Create the modal element.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// folderManager.js
|
||||
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { openFolderShareModal } from './folderShareModal.js';
|
||||
import { fetchWithCsrf } from './auth.js';
|
||||
import { loadCsrfToken } from './main.js';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
|
||||
import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
|
||||
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
|
||||
|
||||
/* ----------------------
|
||||
Helpers: safe JSON + state
|
||||
@@ -86,26 +86,27 @@ export function getParentFolder(folder) {
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function setControlEnabled(el, enabled) {
|
||||
if (!el) return;
|
||||
if ('disabled' in el) el.disabled = !enabled;
|
||||
el.classList.toggle('disabled', !enabled);
|
||||
el.setAttribute('aria-disabled', String(!enabled));
|
||||
el.style.pointerEvents = enabled ? '' : 'none';
|
||||
el.style.opacity = enabled ? '' : '0.5';
|
||||
}
|
||||
|
||||
async function applyFolderCapabilities(folder) {
|
||||
try {
|
||||
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const caps = await res.json();
|
||||
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const caps = await res.json();
|
||||
window.currentFolderCaps = caps;
|
||||
|
||||
// top buttons
|
||||
const createBtn = document.getElementById('createFolderBtn');
|
||||
const renameBtn = document.getElementById('renameFolderBtn');
|
||||
const deleteBtn = document.getElementById('deleteFolderBtn');
|
||||
const shareBtn = document.getElementById('shareFolderBtn');
|
||||
|
||||
if (createBtn) createBtn.disabled = !caps.canCreate;
|
||||
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
|
||||
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
|
||||
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
|
||||
|
||||
// keep for later if you want context menu to reflect caps
|
||||
window.currentFolderCaps = caps;
|
||||
} catch {}
|
||||
const isRoot = (folder === 'root');
|
||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
|
||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||
}
|
||||
|
||||
// --- Breadcrumb Delegation Setup ---
|
||||
@@ -146,6 +147,7 @@ function breadcrumbClickHandler(e) {
|
||||
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
|
||||
loadFileList(folder);
|
||||
}
|
||||
@@ -179,6 +181,49 @@ function breadcrumbDropHandler(e) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
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] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
|
||||
@@ -261,7 +306,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
} else {
|
||||
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) {
|
||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
}
|
||||
@@ -311,13 +356,58 @@ function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
let dragData = null;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
const jsonStr = event.dataTransfer.getData("application/json") || "";
|
||||
if (jsonStr) dragData = JSON.parse(jsonStr);
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Invalid drag data", e);
|
||||
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] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
|
||||
@@ -458,6 +548,14 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
// Attach drag/drop event listeners.
|
||||
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("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
@@ -486,6 +584,14 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
// Folder-option click: update selection, breadcrumbs, and file list
|
||||
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) {
|
||||
e.stopPropagation();
|
||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
@@ -641,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() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
@@ -824,6 +968,7 @@ function folderManagerContextMenuHandler(e) {
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
window.currentFolder = folder;
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
|
||||
// Visual selection
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
@@ -839,6 +984,10 @@ function folderManagerContextMenuHandler(e) {
|
||||
if (input) input.focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t("move_folder"),
|
||||
action: () => { openMoveFolderUI(folder); }
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => { openRenameFolderModal(); }
|
||||
@@ -921,4 +1070,53 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
|
||||
// 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
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function openFolderShareModal(folder) {
|
||||
// Remove any existing modal
|
||||
|
||||
@@ -216,6 +216,7 @@ const translations = {
|
||||
"spanish": "Spanish",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"chinese_simplified": "Chinese (Simplified)",
|
||||
"use_totp_code_instead": "Use TOTP Code instead",
|
||||
"submit_recovery_code": "Submit Recovery Code",
|
||||
"please_enter_recovery_code": "Please enter your recovery code.",
|
||||
@@ -246,7 +247,7 @@ const translations = {
|
||||
"login_options": "Login Options",
|
||||
"disable_login_form": "Disable Login Form",
|
||||
"disable_basic_http_auth": "Disable Basic HTTP Auth",
|
||||
"disable_oidc_login": "Disable OIDC Login",
|
||||
"disable_oidc_login": "Disable OIDC Login (OIDC Config Required to enable)",
|
||||
"save_settings": "Save Settings",
|
||||
"at_least_one_login_method": "At least one login method must remain enabled.",
|
||||
"settings_updated_successfully": "Settings updated successfully.",
|
||||
@@ -275,7 +276,43 @@ const translations = {
|
||||
"newfile_placeholder": "New file name",
|
||||
"file_created_successfully": "File created successfully!",
|
||||
"error_creating_file": "Error creating file",
|
||||
"file_created": "File created successfully!"
|
||||
"file_created": "File created successfully!",
|
||||
"no_access_to_resource": "You do not have access to this resource.",
|
||||
"can_share": "Can Share",
|
||||
"bypass_ownership": "Bypass Ownership",
|
||||
"error_loading_user_grants": "Error loading user grants",
|
||||
"click_to_edit": "Click to edit",
|
||||
"folder_access": "Folder Access",
|
||||
"move_folder": "Move Folder",
|
||||
"move_folder_message": "Select a destination folder to move this folder to:",
|
||||
"move_folder_title": "Move this folder",
|
||||
"move_folder_success": "Folder moved successfully.",
|
||||
"move_folder_error": "Error moving folder.",
|
||||
"move_folder_invalid": "Invalid source or destination folder.",
|
||||
"move_folder_denied": "You do not have permission to move this folder.",
|
||||
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
|
||||
"move_folder_same_owner": "Source and destination must have the same owner.",
|
||||
"move_folder_confirm": "Are you sure you want to move this folder?",
|
||||
"move_folder_select_dest": "Select a destination folder",
|
||||
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
|
||||
"acl_move_folder_label": "Move Folder (source)",
|
||||
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
|
||||
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
|
||||
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
|
||||
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||
"context_move_folder": "Move Folder...",
|
||||
"context_move_here": "Move Here",
|
||||
"context_move_cancel": "Cancel Move",
|
||||
"mark_as_viewed": "Mark as viewed",
|
||||
"viewed": "Viewed",
|
||||
"resumed_from": "Resumed from",
|
||||
"clear_progress": "Clear progress",
|
||||
"marked_viewed": "Marked as viewed",
|
||||
"progress_cleared": "Progress cleared",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"watched": "Watched",
|
||||
"reset_progress": "Reset Progress"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
@@ -458,6 +495,7 @@ const translations = {
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"german": "Alemán",
|
||||
"chinese_simplified": "Chino (simplificado)",
|
||||
"use_totp_code_instead": "Usar código TOTP en su lugar",
|
||||
"submit_recovery_code": "Enviar código de recuperación",
|
||||
"please_enter_recovery_code": "Por favor, ingrese su código de recuperación.",
|
||||
@@ -686,6 +724,7 @@ const translations = {
|
||||
"spanish": "Espagnol",
|
||||
"french": "Français",
|
||||
"german": "Allemand",
|
||||
"chinese_simplified": "Chinois (simplifié)",
|
||||
"use_totp_code_instead": "Utiliser le code TOTP à la place",
|
||||
"submit_recovery_code": "Soumettre le code de récupération",
|
||||
"please_enter_recovery_code": "Veuillez entrer votre code de récupération.",
|
||||
@@ -923,6 +962,7 @@ const translations = {
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
"german": "Deutsch",
|
||||
"chinese_simplified": "Chinesisch (vereinfacht)",
|
||||
"use_totp_code_instead": "Stattdessen TOTP-Code verwenden",
|
||||
"submit_recovery_code": "Wiederherstellungscode absenden",
|
||||
"please_enter_recovery_code": "Bitte geben Sie Ihren Wiederherstellungscode ein.",
|
||||
@@ -972,7 +1012,275 @@ const translations = {
|
||||
"show": "Zeige",
|
||||
"items_per_page": "elemente pro seite",
|
||||
"columns": "Spalten"
|
||||
},
|
||||
"zh-CN": {
|
||||
"please_log_in_to_continue": "请登录以继续。",
|
||||
"no_files_selected": "未选择文件。",
|
||||
"confirm_delete_files": "确定要删除所选的 {count} 个文件吗?",
|
||||
"element_not_found": "未找到 ID 为 \"{id}\" 的元素。",
|
||||
"search_placeholder": "搜索文件、标签和上传者…",
|
||||
"search_placeholder_advanced": "高级搜索:文件、标签、上传者和内容…",
|
||||
"basic_search_tooltip": "基础搜索:按文件名、标签和上传者搜索。",
|
||||
"advanced_search_tooltip": "高级搜索:包括文件内容、文件名、标签和上传者。",
|
||||
"file_name": "文件名",
|
||||
"date_modified": "修改日期",
|
||||
"upload_date": "上传日期",
|
||||
"file_size": "文件大小",
|
||||
"uploader": "上传者",
|
||||
"enter_totp_code": "输入 TOTP 验证码",
|
||||
"use_recovery_code_instead": "改用恢复代码",
|
||||
"enter_recovery_code": "输入恢复代码",
|
||||
"editing": "正在编辑",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "保存",
|
||||
"close": "关闭",
|
||||
"no_files_found": "未找到文件。",
|
||||
"switch_to_table_view": "切换到表格视图",
|
||||
"switch_to_gallery_view": "切换到图库视图",
|
||||
"share_file": "分享文件",
|
||||
"set_expiration": "设置到期时间:",
|
||||
"password_optional": "密码(可选):",
|
||||
"generate_share_link": "生成分享链接",
|
||||
"shareable_link": "可分享链接:",
|
||||
"copy_link": "复制链接",
|
||||
"tag_file": "标记文件",
|
||||
"tag_name": "标签名称:",
|
||||
"tag_color": "标签颜色:",
|
||||
"save_tag": "保存标签",
|
||||
"light_mode": "浅色模式",
|
||||
"dark_mode": "深色模式",
|
||||
"upload_instruction": "将文件/文件夹拖到此处,或点击“选择文件”",
|
||||
"no_files_selected_default": "未选择文件",
|
||||
"choose_files": "选择文件",
|
||||
"delete_selected": "删除所选",
|
||||
"copy_selected": "复制所选",
|
||||
"move_selected": "移动所选",
|
||||
"tag_selected": "标记所选",
|
||||
"download_zip": "下载 ZIP",
|
||||
"extract_zip": "解压 ZIP",
|
||||
"preview": "预览",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"trash_empty": "回收站为空。",
|
||||
"no_trash_selected": "未选择要还原的回收站项目。",
|
||||
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"header_title_text": "标题文本",
|
||||
"logout": "退出登录",
|
||||
"change_password": "更改密码",
|
||||
"restore_text": "还原或",
|
||||
"delete_text": "删除回收站项目",
|
||||
"restore_selected": "还原所选",
|
||||
"restore_all": "全部还原",
|
||||
"delete_selected_trash": "删除所选",
|
||||
"delete_all": "全部删除",
|
||||
"upload_header": "上传文件/文件夹",
|
||||
|
||||
"folder_navigation": "文件夹导航与管理",
|
||||
"create_folder": "创建文件夹",
|
||||
"create_folder_title": "创建文件夹",
|
||||
"enter_folder_name": "输入文件夹名称",
|
||||
"cancel": "取消",
|
||||
"create": "创建",
|
||||
"rename_folder": "重命名文件夹",
|
||||
"rename_folder_title": "重命名文件夹",
|
||||
"rename_folder_placeholder": "输入新的文件夹名称",
|
||||
"delete_folder": "删除文件夹",
|
||||
"delete_folder_title": "删除文件夹",
|
||||
"delete_folder_message": "确定要删除此文件夹吗?",
|
||||
"folder_help": "文件夹帮助",
|
||||
"folder_help_item_1": "点击文件夹以查看其中的文件。",
|
||||
"folder_help_item_2": "使用 [-] 折叠,使用 [+] 展开文件夹。",
|
||||
"folder_help_item_3": "选择一个文件夹并点击“创建文件夹”以添加子文件夹。",
|
||||
"folder_help_item_4": "要重命名或删除文件夹,请选择后点击相应按钮。",
|
||||
|
||||
"actions": "操作",
|
||||
"file_list_title": "文件列表(根目录)",
|
||||
"files_in": "文件位于",
|
||||
"delete_files": "删除文件",
|
||||
"delete_selected_files_title": "删除所选文件",
|
||||
"delete_files_message": "确定要删除所选文件吗?",
|
||||
"copy_files": "复制文件",
|
||||
"copy_files_title": "复制所选文件",
|
||||
"copy_files_message": "选择目标文件夹以复制所选文件:",
|
||||
"move_files": "移动文件",
|
||||
"move_files_title": "移动所选文件",
|
||||
"move_files_message": "选择目标文件夹以移动所选文件:",
|
||||
"move": "移动",
|
||||
"extract_zip_button": "解压 ZIP",
|
||||
"download_zip_title": "将所选文件打包为 ZIP 下载",
|
||||
"download_zip_prompt": "输入 ZIP 文件名:",
|
||||
"zip_placeholder": "files.zip",
|
||||
"share": "分享",
|
||||
"total_files": "文件总数",
|
||||
"total_size": "总大小",
|
||||
"prev": "上一页",
|
||||
"next": "下一页",
|
||||
"page": "第",
|
||||
"of": "页,共",
|
||||
|
||||
"login": "登录",
|
||||
"remember_me": "记住我",
|
||||
"login_oidc": "使用 OIDC 登录",
|
||||
"basic_http_login": "使用基本 HTTP 登录",
|
||||
|
||||
"change_password_title": "更改密码",
|
||||
"old_password": "旧密码",
|
||||
"new_password": "新密码",
|
||||
"confirm_new_password": "确认新密码",
|
||||
|
||||
"create_new_user_title": "创建新用户",
|
||||
"username": "用户名:",
|
||||
"password": "密码:",
|
||||
"enter_password": "密码",
|
||||
"preparing_download": "正在准备下载…",
|
||||
"download_file": "下载文件",
|
||||
"confirm_or_change_filename": "确认或修改下载文件名:",
|
||||
"filename": "文件名",
|
||||
"download": "下载",
|
||||
"grant_admin": "授予管理员权限",
|
||||
"save_user": "保存用户",
|
||||
|
||||
"remove_user_title": "删除用户",
|
||||
"select_user_remove": "选择要删除的用户:",
|
||||
"delete_user": "删除用户",
|
||||
|
||||
"rename_file_title": "重命名文件",
|
||||
"rename_file_placeholder": "输入新的文件名",
|
||||
|
||||
"share_folder": "分享文件夹",
|
||||
"allow_uploads": "允许上传",
|
||||
"share_link_generated": "已生成分享链接",
|
||||
"error_generating_share_link": "生成分享链接时出错",
|
||||
"custom": "自定义",
|
||||
"duration": "持续时间",
|
||||
"seconds": "秒",
|
||||
"minutes": "分钟",
|
||||
"hours": "小时",
|
||||
"days": "天",
|
||||
"custom_duration_warning": "⚠️ 使用较长的到期时间可能存在安全风险,请谨慎使用。",
|
||||
|
||||
"folder_share": "分享文件夹",
|
||||
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"unsaved_changes_confirm": "您有未保存的更改,确定要关闭而不保存吗?",
|
||||
"delete": "删除",
|
||||
"upload": "上传",
|
||||
"copy": "复制",
|
||||
"extract": "解压",
|
||||
"user": "用户:",
|
||||
"unknown_error": "未知错误",
|
||||
"link_copied": "链接已复制到剪贴板",
|
||||
"weeks": "周",
|
||||
"months": "月",
|
||||
|
||||
"dark_mode_toggle": "深色模式",
|
||||
"light_mode_toggle": "浅色模式",
|
||||
"switch_to_light_mode": "切换到浅色模式",
|
||||
"switch_to_dark_mode": "切换到深色模式",
|
||||
|
||||
"header_settings": "标题设置",
|
||||
"shared_max_upload_size_bytes_title": "共享最大上传大小",
|
||||
"shared_max_upload_size_bytes": "共享最大上传大小(字节)",
|
||||
"max_bytes_shared_uploads_note": "请输入共享文件夹上传的最大允许字节数",
|
||||
"manage_shared_links": "管理分享链接",
|
||||
"folder_shares": "文件夹分享",
|
||||
"file_shares": "文件分享",
|
||||
"loading": "正在加载…",
|
||||
"error_loading_share_links": "加载分享链接时出错",
|
||||
"share_deleted_successfully": "分享已成功删除",
|
||||
"error_deleting_share": "删除分享时出错",
|
||||
"password_protected": "受密码保护",
|
||||
"no_shared_links_available": "暂无可用的分享链接",
|
||||
|
||||
"admin_panel": "管理员面板",
|
||||
"user_panel": "用户面板",
|
||||
"user_settings": "用户设置",
|
||||
"save_profile_picture": "保存头像",
|
||||
"please_select_picture": "请选择图片",
|
||||
"profile_picture_updated": "头像已更新",
|
||||
"error_updating_picture": "更新头像时出错",
|
||||
"trash_restore_delete": "回收站恢复/删除",
|
||||
"totp_settings": "TOTP 设置",
|
||||
"enable_totp": "启用 TOTP",
|
||||
"language": "语言",
|
||||
"select_language": "选择语言",
|
||||
"english": "英语",
|
||||
"spanish": "西班牙语",
|
||||
"french": "法语",
|
||||
"german": "德语",
|
||||
"chinese_simplified": "简体中文",
|
||||
"use_totp_code_instead": "改用 TOTP 验证码",
|
||||
"submit_recovery_code": "提交恢复代码",
|
||||
"please_enter_recovery_code": "请输入您的恢复代码。",
|
||||
"recovery_code_verification_failed": "恢复代码验证失败",
|
||||
"error_verifying_recovery_code": "验证恢复代码时出错",
|
||||
"totp_verification_failed": "TOTP 验证失败",
|
||||
"error_verifying_totp_code": "验证 TOTP 代码时出错",
|
||||
"totp_setup": "TOTP 设置",
|
||||
"scan_qr_code": "请使用验证器应用扫描此二维码。",
|
||||
"enter_totp_confirmation": "输入应用生成的 6 位验证码以确认设置:",
|
||||
"confirm": "确认",
|
||||
"please_enter_valid_code": "请输入有效的 6 位验证码。",
|
||||
"totp_enabled_successfully": "TOTP 启用成功。",
|
||||
"error_generating_recovery_code": "生成恢复代码时出错",
|
||||
"error_loading_qr_code": "加载二维码时出错。",
|
||||
"error_disabling_totp_setting": "禁用 TOTP 设置时出错",
|
||||
"user_management": "用户管理",
|
||||
"add_user": "添加用户",
|
||||
"remove_user": "删除用户",
|
||||
"user_permissions": "用户权限",
|
||||
"oidc_configuration": "OIDC 配置",
|
||||
"oidc_provider_url": "OIDC 提供者 URL",
|
||||
"oidc_client_id": "OIDC 客户端 ID",
|
||||
"oidc_client_secret": "OIDC 客户端密钥",
|
||||
"oidc_redirect_uri": "OIDC 重定向 URI",
|
||||
"global_totp_settings": "全局 TOTP 设置",
|
||||
"global_otpauth_url": "全局 OTPAuth URL",
|
||||
"login_options": "登录选项",
|
||||
"disable_login_form": "禁用登录表单",
|
||||
"disable_basic_http_auth": "禁用基本 HTTP 认证",
|
||||
"disable_oidc_login": "禁用 OIDC 登录",
|
||||
"save_settings": "保存设置",
|
||||
"at_least_one_login_method": "至少保留一种登录方式。",
|
||||
"settings_updated_successfully": "设置已成功更新。",
|
||||
"error_updating_settings": "更新设置时出错",
|
||||
"user_permissions_updated_successfully": "用户权限已成功更新。",
|
||||
"error_updating_permissions": "更新权限时出错",
|
||||
"no_users_found": "未找到用户。",
|
||||
"user_folder_only": "仅限用户文件夹",
|
||||
"read_only": "只读",
|
||||
"disable_upload": "禁用上传",
|
||||
"error_loading_users": "加载用户时出错",
|
||||
"save_permissions": "保存权限",
|
||||
"your_recovery_code": "您的恢复代码",
|
||||
"please_save_recovery_code": "请妥善保存此代码。此代码仅显示一次且只能使用一次。",
|
||||
"ok": "确定",
|
||||
"show": "显示",
|
||||
"items_per_page": "每页项目数",
|
||||
"columns": "列",
|
||||
"row_height": "行高",
|
||||
"api_docs": "API 文档",
|
||||
"show_folders_above_files": "在文件上方显示文件夹",
|
||||
"display": "显示",
|
||||
"create_file": "创建文件",
|
||||
"create_new_file": "创建新文件",
|
||||
"enter_file_name": "输入文件名",
|
||||
"newfile_placeholder": "新文件名",
|
||||
"file_created_successfully": "文件创建成功!",
|
||||
"error_creating_file": "创建文件时出错",
|
||||
"file_created": "文件创建成功!",
|
||||
"no_access_to_resource": "您无权访问此资源。",
|
||||
"can_share": "可分享",
|
||||
"bypass_ownership": "绕过所有权限制",
|
||||
"error_loading_user_grants": "加载用户授权时出错",
|
||||
"click_to_edit": "点击编辑",
|
||||
"folder_access": "文件夹访问"
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
let currentLocale = 'en';
|
||||
|
||||
1431
public/js/main.js
365
public/js/mobile/switcher.js
Normal file
@@ -0,0 +1,365 @@
|
||||
(function(){
|
||||
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
|
||||
if (!isCap) return;
|
||||
// NOTE: allow running inside Capacitor (origin "capacitor://localhost")
|
||||
|
||||
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
|
||||
const Pref = Plugins.Preferences ? {
|
||||
get: ({key}) => Plugins.Preferences.get({key}),
|
||||
set: ({key,value}) => Plugins.Preferences.set({key,value}),
|
||||
remove:({key}) => Plugins.Preferences.remove({key})
|
||||
} : {
|
||||
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
|
||||
set: async ({key,value}) => localStorage.setItem(key, value),
|
||||
remove: async ({key}) => localStorage.removeItem(key)
|
||||
};
|
||||
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
|
||||
|
||||
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
|
||||
// Safe element builder: attributes only, children as nodes/strings (no innerHTML)
|
||||
const el = (tag, attrs = {}, children = []) => {
|
||||
const n = document.createElement(tag);
|
||||
for (const k in attrs) n.setAttribute(k, attrs[k]);
|
||||
(Array.isArray(children) ? children : [children]).forEach(c => {
|
||||
if (c == null) return;
|
||||
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
});
|
||||
return n;
|
||||
};
|
||||
|
||||
// Normalize to http(s), strip creds, collapse trailing slashes
|
||||
const normalize = (u) => {
|
||||
if (!u) return '';
|
||||
let v = u.trim();
|
||||
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
|
||||
try {
|
||||
const url = new URL(v);
|
||||
if (!/^https?:$/.test(url.protocol)) return '';
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.pathname = url.pathname.replace(/\/+$/,'');
|
||||
return url.toString();
|
||||
} catch { return ''; }
|
||||
};
|
||||
|
||||
// Append/overwrite a query param safely on a normalized URL
|
||||
const withParam = (base, k, v) => {
|
||||
try {
|
||||
const u = new URL(normalize(base));
|
||||
u.searchParams.set(k, v);
|
||||
return u.toString();
|
||||
} catch { return ''; }
|
||||
};
|
||||
|
||||
const host = u => {
|
||||
try { return new URL(normalize(u)).hostname; } catch { return ''; }
|
||||
};
|
||||
const originOf = u => {
|
||||
try { return new URL(normalize(u)).origin; } catch { return ''; }
|
||||
};
|
||||
const faviconUrl = u => {
|
||||
try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; }
|
||||
};
|
||||
const initialsIcon = (hn='FR') => {
|
||||
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
|
||||
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
|
||||
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
|
||||
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
|
||||
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
|
||||
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
async function getStatusCache(){
|
||||
const raw=(await Pref.get({key:K_STATUS})).value;
|
||||
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
|
||||
}
|
||||
async function writeStatus(origin, ok){
|
||||
const cache=await getStatusCache();
|
||||
cache[origin]={ ok, ts: Date.now() };
|
||||
await Pref.set({key:K_STATUS, value:JSON.stringify(cache)});
|
||||
}
|
||||
|
||||
async function verifyFileRise(u, timeout=5000){
|
||||
if (!u || !Http) return {ok:false};
|
||||
const base = normalize(u), org = originOf(base);
|
||||
const tryJson = async (url, validate) => {
|
||||
try{
|
||||
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
|
||||
if (r && r.data) {
|
||||
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
|
||||
return !!validate(j);
|
||||
}
|
||||
}catch(_){}
|
||||
return false;
|
||||
};
|
||||
if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org};
|
||||
if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||
if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||
try{
|
||||
const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org};
|
||||
}catch(_){}
|
||||
return {ok:false, origin:org};
|
||||
}
|
||||
|
||||
async function probeReachable(u, timeout=3000){
|
||||
try{
|
||||
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
|
||||
if (Http){
|
||||
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
|
||||
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
|
||||
return false;
|
||||
}
|
||||
return await new Promise(res=>{
|
||||
const img=new Image(), t=setTimeout(()=>done(false), timeout);
|
||||
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
|
||||
img.onload=()=>done(true); img.onerror=()=>done(false);
|
||||
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
|
||||
});
|
||||
}catch{ return false; }
|
||||
}
|
||||
|
||||
async function loadInstances(){
|
||||
const raw=(await Pref.get({key:K_INST})).value;
|
||||
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
|
||||
}
|
||||
async function saveInstances(list){
|
||||
await Pref.set({key:K_INST, value:JSON.stringify(list)});
|
||||
}
|
||||
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
|
||||
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
|
||||
|
||||
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
|
||||
if (!$('#frx-mobile-style')) {
|
||||
const css = `
|
||||
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
|
||||
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
|
||||
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
|
||||
.frx-fab:active { transform: translateY(1px) scale(.98); }
|
||||
.frx-fab svg { width:26px; height:26px; fill:white }
|
||||
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
|
||||
.frx-scrim.show{opacity:1;visibility:visible}
|
||||
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
|
||||
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
|
||||
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
|
||||
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
|
||||
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
|
||||
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
|
||||
.frx-title img{width:22px;height:22px}
|
||||
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
|
||||
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
|
||||
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
|
||||
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||
.frx-left{display:flex;gap:10px;align-items:center}
|
||||
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
|
||||
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.frx-name{font-weight:800}
|
||||
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
|
||||
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
|
||||
.frx-dot{width:10px;height:10px;border-radius:50%;}
|
||||
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
|
||||
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
|
||||
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
|
||||
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
|
||||
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
|
||||
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
|
||||
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
|
||||
.frx-row{display:flex;gap:8px;align-items:center}
|
||||
.frx-field{display:grid;gap:6px;margin:8px 4px}
|
||||
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
|
||||
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
|
||||
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
|
||||
`;
|
||||
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
|
||||
}
|
||||
|
||||
// ---- DOM skeleton (no innerHTML) ----
|
||||
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
|
||||
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'});
|
||||
const hdr = el('div',{class:'hdr'});
|
||||
const title = el('div',{class:'frx-title'});
|
||||
const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'});
|
||||
// inline handler via property, not attribute
|
||||
logo.onerror = function(){ this.style.display='none'; };
|
||||
title.append(logo, el('span',{},'FileRise Switcher'));
|
||||
const hdrBtns = el('div',{class:'frx-row'},[
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'),
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close')
|
||||
]);
|
||||
hdr.append(title, hdrBtns);
|
||||
|
||||
const list = el('div',{class:'frx-list', id:'frx-list'});
|
||||
const formWrap = el('div',{style:'padding:10px 12px'},[
|
||||
el('div',{class:'frx-field'},[
|
||||
el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}),
|
||||
el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'})
|
||||
])
|
||||
]);
|
||||
const footer = el('div',{class:'frx-footer'},[
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'),
|
||||
el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server')
|
||||
]);
|
||||
sheet.append(hdr, list, formWrap, footer);
|
||||
|
||||
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[
|
||||
el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ])
|
||||
]);
|
||||
|
||||
document.body.appendChild(scrim);
|
||||
document.body.appendChild(sheet);
|
||||
document.body.appendChild(fab);
|
||||
|
||||
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
|
||||
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
|
||||
$('#frx-close').addEventListener('click', hide);
|
||||
$('#frx-add-cancel').addEventListener('click', hide);
|
||||
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
|
||||
scrim.addEventListener('click', hide);
|
||||
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
|
||||
|
||||
function chipNode(item, isActive){
|
||||
const hv = host(item.url);
|
||||
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
|
||||
|
||||
const top = el('div',{class:'frx-top'});
|
||||
const left = el('div',{class:'frx-left'});
|
||||
|
||||
const ico = el('div',{class:'frx-ico'});
|
||||
const img = new Image();
|
||||
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
|
||||
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
|
||||
ico.appendChild(img);
|
||||
|
||||
const txt = el('div',{},[
|
||||
el('div',{class:'frx-name'}, (item.name || hv)),
|
||||
el('div',{class:'frx-host'}, hv)
|
||||
]);
|
||||
|
||||
left.appendChild(ico);
|
||||
left.appendChild(txt);
|
||||
|
||||
const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`});
|
||||
const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…');
|
||||
const status = el('div',{class:'frx-status'}, [dot, lbl]);
|
||||
|
||||
top.appendChild(left);
|
||||
top.appendChild(status);
|
||||
|
||||
const actions = el('div',{class:'frx-actions'});
|
||||
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
|
||||
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
|
||||
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
|
||||
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
|
||||
|
||||
node.appendChild(top);
|
||||
node.appendChild(actions);
|
||||
return node;
|
||||
}
|
||||
|
||||
async function renderList(){
|
||||
const listEl=$('#frx-list'); listEl.textContent='';
|
||||
const list=await loadInstances(); const active=await getActive();
|
||||
const cache=await getStatusCache();
|
||||
|
||||
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
|
||||
const chip = chipNode(item, item.id===active);
|
||||
const o = originOf(item.url), cached = cache[o];
|
||||
const dot = chip.querySelector(`#frx-dot-${item.id}`);
|
||||
const lbl = chip.querySelector(`#frx-lbl-${item.id}`);
|
||||
const openBtn = chip.querySelector('[data-act="open"]');
|
||||
|
||||
if (cached){
|
||||
dot.classList.add(cached.ok ? 'on':'off');
|
||||
lbl.textContent = cached.ok ? 'Online' : 'Offline';
|
||||
openBtn.disabled = !cached.ok;
|
||||
} else {
|
||||
lbl.textContent = 'Unknown';
|
||||
openBtn.disabled = true;
|
||||
}
|
||||
|
||||
chip.addEventListener('click', async (e)=>{
|
||||
const act = e.target?.dataset?.act;
|
||||
if (!act) return;
|
||||
|
||||
if (act==='open'){
|
||||
if (openBtn.disabled) return;
|
||||
await setActive(item.id);
|
||||
const dest = withParam(item.url, 'frapp', '1');
|
||||
if (dest) window.location.replace(dest);
|
||||
} else if (act==='rename'){
|
||||
const nn=prompt('New display name:', item.name || host(item.url));
|
||||
if (nn!=null){
|
||||
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
|
||||
if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
|
||||
}
|
||||
} else if (act==='remove'){
|
||||
if (!confirm('Remove this server?')) return;
|
||||
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
|
||||
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
|
||||
}
|
||||
});
|
||||
|
||||
listEl.appendChild(chip);
|
||||
|
||||
// Live refresh (best effort)
|
||||
(async ()=>{
|
||||
const ok = await probeReachable(item.url, 2500);
|
||||
const d = document.getElementById(`frx-dot-${item.id}`);
|
||||
const l = document.getElementById(`frx-lbl-${item.id}`);
|
||||
const b = chip.querySelector('[data-act="open"]');
|
||||
if (d && l && b){
|
||||
d.classList.remove('on','off');
|
||||
d.classList.add(ok?'on':'off');
|
||||
l.textContent = ok ? 'Online' : 'Offline';
|
||||
b.disabled = !ok;
|
||||
}
|
||||
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
$('#frx-add-save').addEventListener('click', async ()=>{
|
||||
const name = $('#frx-name').value.trim();
|
||||
const url = $('#frx-url').value.trim();
|
||||
if (!url) { alert('Enter a valid URL'); return; }
|
||||
|
||||
// Verify: must be FileRise
|
||||
const vf = await verifyFileRise(url);
|
||||
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
|
||||
|
||||
let L = await loadInstances();
|
||||
const h = host(url);
|
||||
const dupe = L.find(i => host(i.url)===h);
|
||||
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
|
||||
inst.name = name || inst.name || h;
|
||||
inst.url = normalize(url);
|
||||
inst.favicon = faviconUrl(url);
|
||||
inst.lastUsed = Date.now();
|
||||
if (!dupe) L.push(inst);
|
||||
await saveInstances(L);
|
||||
await setActive(inst.id);
|
||||
|
||||
if (vf.origin) await writeStatus(vf.origin, true);
|
||||
|
||||
const dest = withParam(inst.url, 'frapp', '1');
|
||||
if (dest) window.location.replace(dest);
|
||||
});
|
||||
|
||||
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
|
||||
|
||||
// Ensure zoom gestures work if the host page tried to disable them
|
||||
(function ensureZoomable(){
|
||||
let m = document.querySelector('meta[name=viewport]');
|
||||
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
|
||||
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
|
||||
const c = m.getAttribute('content') || '';
|
||||
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
|
||||
})();
|
||||
})();
|
||||
5
public/js/pwa/register-sw.js
Normal file
@@ -0,0 +1,5 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
|
||||
});
|
||||
}
|
||||
9
public/js/pwa/sw.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// public/js/pwa/sw.js
|
||||
const SW_VERSION = '{{APP_QVER}}';
|
||||
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
|
||||
const STATIC_ASSETS = [
|
||||
'/', '/index.html',
|
||||
'/css/styles.css?v={{APP_QVER}}',
|
||||
'/js/main.js?v={{APP_QVER}}',
|
||||
'/assets/logo.svg?v={{APP_QVER}}'
|
||||
];
|
||||
@@ -1,9 +1,9 @@
|
||||
// trashRestoreDelete.js
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { t } from './i18n.js';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
function showConfirm(message, onConfirm) {
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { initFileActions } from './fileActions.js';
|
||||
import { displayFilePreview } from './filePreview.js';
|
||||
import { showToast, escapeHTML } from './domUtils.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
/* -----------------------------------------------------
|
||||
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||
@@ -36,6 +36,38 @@ function traverseFileTreePromise(item, path = "") {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lazy loader for Resumable.js (no CSP inline, cached, safe) ---
|
||||
const RESUMABLE_SRC = '/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}';
|
||||
let _resumableLoadPromise = null;
|
||||
|
||||
function loadScriptOnce(src) {
|
||||
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
|
||||
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
|
||||
const p = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
loadScriptOnce._cache.set(src, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function lazyLoadResumable() {
|
||||
if (window.Resumable) return Promise.resolve(window.Resumable);
|
||||
if (!_resumableLoadPromise) {
|
||||
_resumableLoadPromise = loadScriptOnce(RESUMABLE_SRC).then(() => window.Resumable);
|
||||
}
|
||||
return _resumableLoadPromise;
|
||||
}
|
||||
|
||||
// Optional: let main.js prefetch it in the background
|
||||
export function warmUpResumable() {
|
||||
lazyLoadResumable().catch(() => {/* ignore warm-up failure */});
|
||||
}
|
||||
|
||||
// Recursively retrieve files from DataTransfer items.
|
||||
function getFilesFromDataTransferItems(items) {
|
||||
const promises = [];
|
||||
@@ -161,91 +193,91 @@ function createFileEntry(file) {
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.classList.add("remove-file-btn");
|
||||
removeBtn.textContent = "×";
|
||||
// In your remove button event listener, replace the fetch call with:
|
||||
removeBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const uploadIndex = file.uploadIndex;
|
||||
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
||||
|
||||
// Cancel the file upload if possible.
|
||||
if (typeof file.cancel === "function") {
|
||||
file.cancel();
|
||||
console.log("Canceled file upload:", file.fileName);
|
||||
}
|
||||
|
||||
// Remove file from the resumable queue.
|
||||
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
||||
resumableInstance.removeFile(file);
|
||||
}
|
||||
|
||||
// Call our helper repeatedly to remove the chunk folder.
|
||||
if (file.uniqueIdentifier) {
|
||||
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
||||
}
|
||||
|
||||
li.remove();
|
||||
updateFileInfoCount();
|
||||
});
|
||||
// In your remove button event listener, replace the fetch call with:
|
||||
removeBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const uploadIndex = file.uploadIndex;
|
||||
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
||||
|
||||
// Cancel the file upload if possible.
|
||||
if (typeof file.cancel === "function") {
|
||||
file.cancel();
|
||||
console.log("Canceled file upload:", file.fileName);
|
||||
}
|
||||
|
||||
// Remove file from the resumable queue.
|
||||
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
||||
resumableInstance.removeFile(file);
|
||||
}
|
||||
|
||||
// Call our helper repeatedly to remove the chunk folder.
|
||||
if (file.uniqueIdentifier) {
|
||||
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
||||
}
|
||||
|
||||
li.remove();
|
||||
updateFileInfoCount();
|
||||
});
|
||||
li.removeBtn = removeBtn;
|
||||
li.appendChild(removeBtn);
|
||||
|
||||
// Add pause/resume/restart button if the file supports pause/resume.
|
||||
// Conditionally add the pause/resume button only if file.pause is available
|
||||
// Pause/Resume button (for resumable file–picker uploads)
|
||||
if (typeof file.pause === "function") {
|
||||
const pauseResumeBtn = document.createElement("button");
|
||||
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
||||
pauseResumeBtn.classList.add("pause-resume-btn");
|
||||
// Start with pause icon and disable button until upload starts
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
pauseResumeBtn.disabled = true;
|
||||
pauseResumeBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (file.isError) {
|
||||
// If the file previously failed, try restarting upload.
|
||||
if (typeof file.retry === "function") {
|
||||
file.retry();
|
||||
file.isError = false;
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
}
|
||||
} else if (!file.paused) {
|
||||
// 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(() => {
|
||||
// Conditionally add the pause/resume button only if file.pause is available
|
||||
// Pause/Resume button (for resumable file–picker uploads)
|
||||
if (typeof file.pause === "function") {
|
||||
const pauseResumeBtn = document.createElement("button");
|
||||
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
||||
pauseResumeBtn.classList.add("pause-resume-btn");
|
||||
// Start with pause icon and disable button until upload starts
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
pauseResumeBtn.disabled = true;
|
||||
pauseResumeBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (file.isError) {
|
||||
// If the file previously failed, try restarting upload.
|
||||
if (typeof file.retry === "function") {
|
||||
file.retry();
|
||||
file.isError = false;
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
}
|
||||
} else if (!file.paused) {
|
||||
// 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.resume === "function") {
|
||||
file.resume();
|
||||
if (typeof file.pause === "function") {
|
||||
file.pause();
|
||||
} else {
|
||||
resumableInstance.upload();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (typeof file.resume === "function") {
|
||||
file.resume();
|
||||
} else {
|
||||
resumableInstance.upload();
|
||||
}
|
||||
}, 100);
|
||||
}, 100);
|
||||
}, 100);
|
||||
file.paused = false;
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
} else {
|
||||
console.error("Pause/resume function not available for file", file);
|
||||
}
|
||||
});
|
||||
li.appendChild(pauseResumeBtn);
|
||||
}
|
||||
file.paused = false;
|
||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||
} else {
|
||||
console.error("Pause/resume function not available for file", file);
|
||||
}
|
||||
});
|
||||
li.appendChild(pauseResumeBtn);
|
||||
}
|
||||
|
||||
// Preview element
|
||||
const preview = document.createElement("div");
|
||||
@@ -401,29 +433,49 @@ function processFiles(filesInput) {
|
||||
Resumable.js Integration for File Picker Uploads
|
||||
(Only files chosen via file input use Resumable; folder uploads use original code.)
|
||||
----------------------------------------------------- */
|
||||
const useResumable = true; // Enable resumable for file picker uploads
|
||||
let resumableInstance;
|
||||
function initResumableUpload() {
|
||||
resumableInstance = new Resumable({
|
||||
target: "/api/upload/upload.php",
|
||||
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
|
||||
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
|
||||
simultaneousUploads: 3,
|
||||
forceChunkSize: true,
|
||||
testChunks: false,
|
||||
throttleProgressCallbacks: 1,
|
||||
withCredentials: true,
|
||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||
query: {
|
||||
folder: window.currentFolder || "root",
|
||||
upload_token: window.csrfToken // still as a fallback
|
||||
}
|
||||
const useResumable = true;
|
||||
let resumableInstance = null;
|
||||
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||
let _resumableReady = false;
|
||||
|
||||
// Make init async-safe; it resolves when Resumable is constructed
|
||||
async function initResumableUpload() {
|
||||
if (resumableInstance) return;
|
||||
// Load the library if needed
|
||||
const ResumableCtor = await lazyLoadResumable().catch(err => {
|
||||
console.error('Failed to load Resumable.js:', err);
|
||||
return null;
|
||||
});
|
||||
if (!ResumableCtor) return;
|
||||
|
||||
// Construct the instance once
|
||||
if (!resumableInstance) {
|
||||
resumableInstance = new ResumableCtor({
|
||||
target: "/api/upload/upload.php",
|
||||
chunkSize: 1.5 * 1024 * 1024,
|
||||
simultaneousUploads: 3,
|
||||
forceChunkSize: true,
|
||||
testChunks: false,
|
||||
withCredentials: true,
|
||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||
query: () => ({
|
||||
folder: window.currentFolder || "root",
|
||||
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;
|
||||
resumableInstance.opts.query.folder = window.currentFolder || 'root';
|
||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById("file");
|
||||
if (fileInput) {
|
||||
// Assign Resumable to file input for file picker uploads.
|
||||
resumableInstance.assignBrowse(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
resumableInstance.addFile(fileInput.files[i]);
|
||||
@@ -432,6 +484,7 @@ function initResumableUpload() {
|
||||
}
|
||||
|
||||
resumableInstance.on("fileAdded", function (file) {
|
||||
|
||||
// Initialize custom paused flag
|
||||
file.paused = false;
|
||||
file.uploadIndex = file.uniqueIdentifier;
|
||||
@@ -461,16 +514,17 @@ function initResumableUpload() {
|
||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||
list.appendChild(li);
|
||||
updateFileInfoCount();
|
||||
updateResumableQuery();
|
||||
});
|
||||
|
||||
resumableInstance.on("fileProgress", function(file) {
|
||||
resumableInstance.on("fileProgress", function (file) {
|
||||
const progress = file.progress(); // value between 0 and 1
|
||||
const percent = Math.floor(progress * 100);
|
||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||
if (li && li.progressBar) {
|
||||
if (percent < 99) {
|
||||
li.progressBar.style.width = percent + "%";
|
||||
|
||||
|
||||
// Calculate elapsed time and speed.
|
||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||
let speed = "";
|
||||
@@ -491,7 +545,7 @@ function initResumableUpload() {
|
||||
li.progressBar.style.width = "100%";
|
||||
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
||||
}
|
||||
|
||||
|
||||
// Enable the pause/resume button once progress starts.
|
||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||
if (pauseResumeBtn) {
|
||||
@@ -499,8 +553,8 @@ function initResumableUpload() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resumableInstance.on("fileSuccess", function(file, message) {
|
||||
|
||||
resumableInstance.on("fileSuccess", function (file, message) {
|
||||
// Try to parse JSON response
|
||||
let data;
|
||||
try {
|
||||
@@ -508,18 +562,18 @@ function initResumableUpload() {
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
|
||||
// 1) Soft‐fail CSRF? then update token & retry this file
|
||||
if (data && data.csrf_expired) {
|
||||
// Update global and Resumable headers
|
||||
window.csrfToken = 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
|
||||
file.retry();
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 2) Otherwise treat as real success:
|
||||
const li = document.querySelector(
|
||||
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||
@@ -531,13 +585,13 @@ function initResumableUpload() {
|
||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
||||
const removeBtn = li.querySelector(".remove-file-btn");
|
||||
if (removeBtn) removeBtn.style.display = "none";
|
||||
if (removeBtn) removeBtn.style.display = "none";
|
||||
setTimeout(() => li.remove(), 5000);
|
||||
}
|
||||
|
||||
|
||||
loadFileList(window.currentFolder);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
resumableInstance.on("fileError", function (file, message) {
|
||||
@@ -578,13 +632,24 @@ function initResumableUpload() {
|
||||
showToast("Some files failed to upload. Please check the list.");
|
||||
}
|
||||
});
|
||||
|
||||
_resumableReady = true;
|
||||
if (_pendingPickedFiles.length) {
|
||||
updateResumableQuery();
|
||||
for (const f of _pendingPickedFiles) resumableInstance.addFile(f);
|
||||
_pendingPickedFiles = [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------
|
||||
XHR-based submitFiles for Drag–and–Drop (Folder) Uploads
|
||||
----------------------------------------------------- */
|
||||
function submitFiles(allFiles) {
|
||||
const folderToUse = window.currentFolder || "root";
|
||||
const folderToUse = (() => {
|
||||
const f = window.currentFolder || "root";
|
||||
try { return decodeURIComponent(f); } catch { return f; }
|
||||
})();
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
const fileInput = document.getElementById("file");
|
||||
|
||||
@@ -637,7 +702,7 @@ function submitFiles(allFiles) {
|
||||
} catch (e) {
|
||||
jsonResponse = null;
|
||||
}
|
||||
|
||||
|
||||
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
||||
if (jsonResponse && jsonResponse.csrf_expired) {
|
||||
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
||||
@@ -650,10 +715,10 @@ function submitFiles(allFiles) {
|
||||
xhr.send(formData);
|
||||
return; // skip the "finishedCount++" and error/success logic for now
|
||||
}
|
||||
|
||||
|
||||
// ─── Normal success/error handling ────────────────────────────
|
||||
const li = progressElements[file.uploadIndex];
|
||||
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||
// real success
|
||||
if (li) {
|
||||
@@ -662,6 +727,7 @@ function submitFiles(allFiles) {
|
||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||
}
|
||||
uploadResults[file.uploadIndex] = true;
|
||||
|
||||
} else {
|
||||
// real failure
|
||||
if (li) {
|
||||
@@ -681,12 +747,17 @@ function submitFiles(allFiles) {
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
// ─── Only now count this chunk as finished ───────────────────
|
||||
finishedCount++;
|
||||
if (finishedCount === allFiles.length) {
|
||||
refreshFileList(allFiles, uploadResults, progressElements);
|
||||
}
|
||||
if (finishedCount === allFiles.length) {
|
||||
const succeededCount = uploadResults.filter(Boolean).length;
|
||||
const failedCount = allFiles.length - succeededCount;
|
||||
|
||||
setTimeout(() => {
|
||||
refreshFileList(allFiles, uploadResults, progressElements);
|
||||
}, 250);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", function () {
|
||||
@@ -699,6 +770,9 @@ function submitFiles(allFiles) {
|
||||
finishedCount++;
|
||||
if (finishedCount === allFiles.length) {
|
||||
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 +799,30 @@ function submitFiles(allFiles) {
|
||||
loadFileList(folderToUse)
|
||||
.then(serverFiles => {
|
||||
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 succeeded = 0;
|
||||
allFiles.forEach(file => {
|
||||
const clientFileName = file.name.trim().toLowerCase();
|
||||
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) {
|
||||
li.progressBar.innerText = "Error";
|
||||
}
|
||||
overallSuccess = false;
|
||||
|
||||
} else if (li) {
|
||||
succeeded++;
|
||||
|
||||
// Schedule removal of successful file entry after 5 seconds.
|
||||
setTimeout(() => {
|
||||
li.remove();
|
||||
@@ -757,9 +844,12 @@ function submitFiles(allFiles) {
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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 => {
|
||||
@@ -768,6 +858,7 @@ function submitFiles(allFiles) {
|
||||
})
|
||||
.finally(() => {
|
||||
loadFolderTree(window.currentFolder);
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -822,32 +913,48 @@ function initUpload() {
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", function () {
|
||||
fileInput.addEventListener("change", async function () {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
if (!files.length) return;
|
||||
|
||||
if (useResumable) {
|
||||
// For file picker, if resumable is enabled, let it handle the files.
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
resumableInstance.addFile(fileInput.files[i]);
|
||||
// Ensure the lib/instance exists
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (resumableInstance) {
|
||||
for (const f of files) resumableInstance.addFile(f);
|
||||
} else {
|
||||
// If still not ready (load error), fall back to your XHR path
|
||||
processFiles(files);
|
||||
}
|
||||
} else {
|
||||
processFiles(fileInput.files);
|
||||
processFiles(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadForm) {
|
||||
uploadForm.addEventListener("submit", function (e) {
|
||||
uploadForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
|
||||
if (!files || files.length === 0) {
|
||||
if (!files || !files.length) {
|
||||
showToast("No files selected.");
|
||||
return;
|
||||
}
|
||||
// If files come from file picker (no relative path), use Resumable.
|
||||
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) {
|
||||
// Ensure current folder is updated.
|
||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||
resumableInstance.upload();
|
||||
showToast("Resumable upload started...");
|
||||
|
||||
// Resumable path (only for picked files, not folder uploads)
|
||||
const first = files[0];
|
||||
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
|
||||
if (useResumable && !isFolderish) {
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (resumableInstance) {
|
||||
// ensure folder/token fresh
|
||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||
resumableInstance.upload();
|
||||
showToast("Resumable upload started...");
|
||||
} else {
|
||||
// fallback
|
||||
submitFiles(files);
|
||||
}
|
||||
} else {
|
||||
submitFiles(files);
|
||||
}
|
||||
|
||||
2
public/js/version.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.8.12';
|
||||
14
public/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "FileRise",
|
||||
"short_name": "FileRise",
|
||||
"start_url": "/?pwa=1",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111111",
|
||||
"theme_color": "#0b5ed7",
|
||||
"icons": [
|
||||
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
6
public/sw.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Root-scoped stub. Keeps the worker’s scope at “/” level
|
||||
try {
|
||||
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
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.
|
||||