Compare commits
353 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0de36e734 | |||
|
|
405ed7f925 | ||
|
|
6491a7b1b3 | ||
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad | ||
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 | ||
|
|
e2d1b705bd | ||
|
|
4798afa89e | ||
|
|
da968e51e1 | ||
|
|
c06452600d | ||
|
|
758ad7719b | ||
|
|
3587f5041c | ||
|
|
da14d204a6 | ||
|
|
2a87002e1f | ||
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d | ||
|
|
997e5067d3 | ||
|
|
1c0ac50048 | ||
|
|
8fc716387b | ||
|
|
fe3a58924b | ||
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 | ||
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 | ||
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d | ||
|
|
5613710411 | ||
|
|
08f7ffccbc | ||
|
|
ad1d41fad8 | ||
|
|
99662cd2f2 | ||
|
|
060a548af4 | ||
|
|
9880adb417 | ||
|
|
a56641e81c | ||
|
|
3b636f69d8 | ||
|
|
930ed954ec | ||
|
|
402f590163 | ||
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
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 | ||
|
|
d29900d6ba | ||
|
|
5ffc068041 | ||
|
|
1935cb2442 | ||
|
|
af9887e651 | ||
|
|
327eea2835 | ||
|
|
3843daa228 | ||
|
|
169e03be5d | ||
|
|
be605b4522 | ||
|
|
090286164d | ||
|
|
dc1649ace3 | ||
|
|
b6d86b7896 | ||
|
|
25ce6a76be | ||
|
|
f2ab2a96bc | ||
|
|
c22c8e0f34 | ||
|
|
070515e7a6 | ||
|
|
7a0f4ddbb4 | ||
|
|
e1c15eb95a | ||
|
|
2400dcb9eb | ||
|
|
c717f8be60 | ||
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 | ||
|
|
16ccb66d55 | ||
|
|
9209f7a582 | ||
|
|
4a736b0224 | ||
|
|
f162a7d0d7 | ||
|
|
3fc526df7f | ||
|
|
20422cf5a7 | ||
|
|
492bab36ca | ||
|
|
f2f7697994 | ||
|
|
13aa011632 | ||
|
|
1add160f5d | ||
|
|
87368143b5 | ||
|
|
939aa032f0 | ||
|
|
fbd21a035b | ||
|
|
2f391d11db | ||
|
|
8c70783d5a | ||
|
|
b4d6f01432 | ||
|
|
d48b15a5f4 | ||
|
|
d1726f0160 | ||
|
|
bd1841b788 | ||
|
|
bde35d1d31 | ||
|
|
8d6a1be777 | ||
|
|
56f34ba362 | ||
|
|
4d329e046f | ||
|
|
f3977153fb | ||
|
|
274bedd186 | ||
|
|
2e4dbe7f7f | ||
|
|
0334e443eb | ||
|
|
76f5ed5c96 | ||
|
|
18f588dc24 | ||
|
|
491c686762 | ||
|
|
25303df677 | ||
|
|
ae0d63b86f | ||
|
|
41ade2e205 | ||
|
|
0a9d332d60 | ||
|
|
1983f7705f | ||
|
|
6b2bf0ba70 | ||
|
|
6d9715169c | ||
|
|
0645a3712a | ||
|
|
ebc32ea965 | ||
|
|
078db33458 | ||
|
|
04f5cbe31f | ||
|
|
b5a7d8d559 | ||
|
|
58f8485b02 | ||
|
|
3e1da9c335 | ||
|
|
6bf6206e1c | ||
|
|
f9c60951c9 | ||
|
|
06b3f28df0 | ||
|
|
89f124250c | ||
|
|
66f13fd6a7 | ||
|
|
a81d9cb940 | ||
|
|
13b8871200 | ||
|
|
2792c05c1c | ||
|
|
6ccfc88acb | ||
|
|
7f1d59b33a | ||
|
|
e4e8b108d2 | ||
|
|
242661a9c9 | ||
|
|
ca3e2f316c | ||
|
|
6ff4aa5f34 | ||
|
|
1eb54b8e6e | ||
|
|
4a6c424540 | ||
|
|
d23d5b7f3f | ||
|
|
a48ba09f02 | ||
|
|
61357af203 | ||
|
|
e390a35e8a | ||
|
|
7e50ba1f70 | ||
|
|
cc41f8cc95 | ||
|
|
7c31b9689f | ||
|
|
461921b7bc | ||
|
|
3b58123584 | ||
|
|
cd9d7eb0ba | ||
|
|
c0c8d68dc4 | ||
|
|
2dfcb4062f | ||
|
|
d839b3ac1c | ||
|
|
766458f707 | ||
|
|
22cce5a898 | ||
|
|
75d3bf5a9b | ||
|
|
4ec4ba832f | ||
|
|
97b67593bc | ||
|
|
ec5c3fc452 | ||
|
|
853d8835d9 | ||
|
|
1d36d002c6 | ||
|
|
844976ef89 | ||
|
|
66e0d7ecbe | ||
|
|
a5fbcdef88 | ||
|
|
a897d1734f | ||
|
|
a9c4200827 | ||
|
|
97559873dc | ||
|
|
0683b27534 | ||
|
|
49c42e8096 | ||
|
|
ed39e112a9 | ||
|
|
25edab923a | ||
|
|
b8ae3c4402 | ||
|
|
fb537b1d61 | ||
|
|
90439022e3 | ||
|
|
b4c8738b8a | ||
|
|
e193bf9b13 | ||
|
|
a70d8fc2c7 | ||
|
|
d9f69d7917 | ||
|
|
28ac23c2f6 | ||
|
|
b06c49f213 | ||
|
|
8553efabc1 | ||
|
|
81a08ffd5b | ||
|
|
296dae96a5 | ||
|
|
337f529afd | ||
|
|
4360f2830a | ||
|
|
894cc938a5 | ||
|
|
01801ba950 | ||
|
|
5b592575a4 | ||
|
|
7cce03d092 | ||
|
|
ff92a6d26c | ||
|
|
4fa5faa2bf | ||
|
|
98850a7c65 | ||
|
|
15bac15c33 | ||
|
|
b2ff3efb3b | ||
|
|
b9ce3f92a4 | ||
|
|
f65b151bc3 | ||
|
|
703c93db25 | ||
|
|
d0353b137b | ||
|
|
a6c4c1d39c | ||
|
|
7aa4fe142a | ||
|
|
9f8337574a | ||
|
|
82eadebe3b | ||
|
|
9701747214 | ||
|
|
6ff25ed426 | ||
|
|
ecc41bfe31 | ||
|
|
94055d2c92 | ||
|
|
5b50400f28 | ||
|
|
688a4bcf52 | ||
|
|
4bcbb08650 | ||
|
|
1a044145ab | ||
|
|
59299cdbed | ||
|
|
4f74090818 | ||
|
|
70163d22f0 | ||
|
|
b4445fc4d8 | ||
|
|
4022ccde84 | ||
|
|
8d370fd1bb | ||
|
|
5100e8bf3b | ||
|
|
899b04e49a | ||
|
|
07053a6b9a | ||
|
|
58db1d49ac |
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
||||
# dockerignore
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.github/**
|
||||
Dockerfile*
|
||||
resources/
|
||||
node_modules/
|
||||
*.log
|
||||
tmp/
|
||||
.env
|
||||
.vscode/
|
||||
.DS_Store
|
||||
data/
|
||||
uploads/
|
||||
users/
|
||||
metadata/
|
||||
sessions/
|
||||
vendor/
|
||||
40
.gitattributes
vendored
Normal file
40
.gitattributes
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# --- Docs that shouldn't count toward code stats
|
||||
public/api.php linguist-documentation
|
||||
public/openapi.json linguist-documentation
|
||||
openapi.json.dist linguist-documentation
|
||||
SECURITY.md linguist-documentation
|
||||
CHANGELOG.md linguist-documentation
|
||||
CONTRIBUTING.md linguist-documentation
|
||||
CODE_OF_CONDUCT.md linguist-documentation
|
||||
LICENSE linguist-documentation
|
||||
README.md linguist-documentation
|
||||
|
||||
# --- Vendored/minified stuff: exclude from Linguist
|
||||
public/vendor/** linguist-vendored
|
||||
public/css/vendor/** linguist-vendored
|
||||
public/fonts/** linguist-vendored
|
||||
public/js/**/*.min.js linguist-vendored
|
||||
public/**/*.min.css linguist-vendored
|
||||
public/**/*.map linguist-generated
|
||||
|
||||
# --- Treat assets as binary (nicer diffs)
|
||||
*.png -diff
|
||||
*.jpg -diff
|
||||
*.jpeg -diff
|
||||
*.gif -diff
|
||||
*.webp -diff
|
||||
*.svg -diff
|
||||
*.ico -diff
|
||||
*.woff -diff
|
||||
*.woff2 -diff
|
||||
*.ttf -diff
|
||||
*.otf -diff
|
||||
*.zip -diff
|
||||
|
||||
# --- Keep these out of auto-generated source archives (OK to ignore)
|
||||
# Only ignore things you *never* need in release tarballs
|
||||
.github/ export-ignore
|
||||
resources/ export-ignore
|
||||
|
||||
# --- Normalize text files
|
||||
* text=auto
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
github: [error311]
|
||||
ko_fi: error311
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
10
.github/ISSUE_TEMPLATE/custom.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/custom.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Custom issue template
|
||||
about: Describe this issue template's purpose here.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
92
.github/workflows/ci.yml
vendored
Normal file
92
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: CI
|
||||
"on":
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
- name: Validate composer.json (if present)
|
||||
run: |
|
||||
if [ -f composer.json ]; then composer validate --no-check-publish; fi
|
||||
- name: Composer audit (if lock present)
|
||||
run: |
|
||||
if [ -f composer.lock ]; then composer audit || true; fi
|
||||
- name: PHP syntax check
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t files < <(git ls-files '*.php')
|
||||
if [ "${#files[@]}" -gt 0 ]; then
|
||||
for f in "${files[@]}"; do php -l "$f"; done
|
||||
else
|
||||
echo "No PHP files found."
|
||||
fi
|
||||
|
||||
shellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: ShellCheck all scripts
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t sh < <(git ls-files '*.sh')
|
||||
if [ "${#sh[@]}" -gt 0 ]; then
|
||||
shellcheck "${sh[@]}"
|
||||
else
|
||||
echo "No shell scripts found."
|
||||
fi
|
||||
|
||||
dockerfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint Dockerfile with hadolint
|
||||
uses: hadolint/hadolint-action@v3.1.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
failure-threshold: error
|
||||
ignore: DL3008,DL3059
|
||||
|
||||
sanity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y jq yamllint
|
||||
- name: Lint JSON
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t jsons < <(git ls-files '*.json' ':!:vendor/**')
|
||||
if [ "${#jsons[@]}" -gt 0 ]; then
|
||||
for j in "${jsons[@]}"; do jq -e . "$j" >/dev/null; done
|
||||
else
|
||||
echo "No JSON files."
|
||||
fi
|
||||
- name: Lint YAML
|
||||
run: |
|
||||
set -e
|
||||
mapfile -t yamls < <(git ls-files '*.yml' '*.yaml')
|
||||
if [ "${#yamls[@]}" -gt 0 ]; then
|
||||
yamllint -d "{extends: default, rules: {line-length: disable, truthy: {check-keys: false}}}" "${yamls[@]}"
|
||||
else
|
||||
echo "No YAML files."
|
||||
fi
|
||||
271
.github/workflows/release-on-version.yml
vendored
Normal file
271
.github/workflows/release-on-version.yml
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
---
|
||||
name: Release on version.js update
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref (branch/sha) to build from (default: master)"
|
||||
required: false
|
||||
version:
|
||||
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Resolve source ref
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
else
|
||||
REF_IN="master"
|
||||
fi
|
||||
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||
REF="$REF_IN"
|
||||
else
|
||||
REF="$REF_IN"
|
||||
fi
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Using ref=$REF"
|
||||
|
||||
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||
VER="${{ github.event.inputs.version }}"
|
||||
else
|
||||
if [[ ! -f public/js/version.js ]]; then
|
||||
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||
exit 1
|
||||
fi
|
||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||
if [[ -z "$VER" ]]; then
|
||||
echo "Could not parse APP_VERSION from public/js/version.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected version: $VER"
|
||||
|
||||
- name: Skip if tag already exists
|
||||
id: tagcheck
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prepare stamp 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 stamped staging tree
|
||||
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"
|
||||
|
||||
# --- PHP + Composer for vendor/ (production) ---
|
||||
- name: Setup PHP
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: php
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
|
||||
coverage: none
|
||||
ini-values: memory_limit=-1
|
||||
|
||||
- name: Cache Composer downloads
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.composer/cache
|
||||
~/.cache/composer
|
||||
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
|
||||
|
||||
- name: Install PHP dependencies into staging
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
env:
|
||||
COMPOSER_MEMORY_LIMIT: -1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pushd staging >/dev/null
|
||||
if [[ -f composer.json ]]; then
|
||||
composer install \
|
||||
--no-dev \
|
||||
--prefer-dist \
|
||||
--no-interaction \
|
||||
--no-progress \
|
||||
--optimize-autoloader \
|
||||
--classmap-authoritative
|
||||
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
|
||||
else
|
||||
echo "No composer.json in staging; skipping vendor install."
|
||||
fi
|
||||
popd >/dev/null
|
||||
# --- end Composer ---
|
||||
|
||||
- name: Verify placeholders removed (skip vendor/)
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT="$(pwd)/staging"
|
||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--exclude-dir=vendor --exclude-dir=vendor-bin \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||
echo "Unreplaced placeholders found in staging." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: No unreplaced placeholders."
|
||||
|
||||
- name: Zip artifact (includes vendor/)
|
||||
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
|
||||
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/baseline: $PREV"
|
||||
|
||||
- name: Build release body
|
||||
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
|
||||
115
.github/workflows/sync-changelog.yml
vendored
Normal file
115
.github/workflows/sync-changelog.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: Bump version and sync Changelog to Docker Repo
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: bump-and-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
bump_and_sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout FileRise
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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 and write VERSION
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp CHANGELOG.md docker-repo/CHANGELOG.md
|
||||
echo "${{ steps.ver.outputs.version }}" > docker-repo/VERSION
|
||||
|
||||
- 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 VERSION
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||
git push origin main
|
||||
fi
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/data/
|
||||
3767
CHANGELOG.md
Normal file
3767
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
288
CLAUDE.md
Normal file
288
CLAUDE.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
FileRise is a self-hosted web file manager / WebDAV server built with PHP 8.3+. It provides drag-and-drop uploads, granular ACL-based permissions, ONLYOFFICE integration, WebDAV support, and OIDC authentication. No external database is required - all data is stored in JSON files.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: PHP 8.3+ (no framework)
|
||||
- Frontend: Vanilla JavaScript, Bootstrap 4.5.2
|
||||
- WebDAV: sabre/dav
|
||||
- Dependencies: Composer (see composer.json)
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running Locally (Docker - Recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The docker-compose.yml file is configured for development. FileRise will be available at http://localhost:8080.
|
||||
|
||||
### Running with PHP Built-in Server
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
2. Create required directories:
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
3. Set environment variables and start:
|
||||
```bash
|
||||
export TIMEZONE="America/New_York"
|
||||
export TOTAL_UPLOAD_SIZE="10G"
|
||||
export SECURE="false"
|
||||
export PERSISTENT_TOKENS_KEY="dev_key_please_change"
|
||||
php -S localhost:8080 -t public/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
FileRise/
|
||||
├── config/
|
||||
│ └── config.php # Global configuration, session handling, encryption
|
||||
├── src/
|
||||
│ ├── controllers/ # Business logic for each feature area
|
||||
│ │ ├── FileController.php # File operations (download, preview, share)
|
||||
│ │ ├── FolderController.php # Folder operations (create, move, copy, delete)
|
||||
│ │ ├── UserController.php # User management
|
||||
│ │ ├── AuthController.php # Authentication (login, OIDC, TOTP)
|
||||
│ │ ├── AdminController.php # Admin panel operations
|
||||
│ │ ├── AclAdminController.php # ACL management
|
||||
│ │ ├── UploadController.php # File upload handling
|
||||
│ │ ├── MediaController.php # Media preview/streaming
|
||||
│ │ ├── OnlyOfficeController.php # ONLYOFFICE document editing
|
||||
│ │ └── PortalController.php # Client portal (Pro feature)
|
||||
│ ├── models/ # Data access layer
|
||||
│ │ ├── UserModel.php
|
||||
│ │ ├── FolderModel.php
|
||||
│ │ ├── FolderMeta.php
|
||||
│ │ ├── MediaModel.php
|
||||
│ │ └── AdminModel.php
|
||||
│ ├── lib/ # Core libraries
|
||||
│ │ ├── ACL.php # Central ACL enforcement (read, write, upload, share, etc.)
|
||||
│ │ └── FS.php # Filesystem utilities and safety checks
|
||||
│ ├── webdav/ # WebDAV implementation (using sabre/dav)
|
||||
│ │ ├── FileRiseFile.php
|
||||
│ │ ├── FileRiseDirectory.php
|
||||
│ │ └── CurrentUser.php
|
||||
│ ├── cli/ # CLI utilities
|
||||
│ └── openapi/ # OpenAPI spec generation
|
||||
├── public/ # Web root (served by Apache/Nginx)
|
||||
│ ├── index.html # Main SPA entry point
|
||||
│ ├── api.php # API documentation viewer
|
||||
│ ├── webdav.php # WebDAV endpoint
|
||||
│ ├── api/ # API endpoints (called by frontend)
|
||||
│ │ ├── *.php # Individual API endpoints
|
||||
│ │ └── pro/ # Pro-only API endpoints
|
||||
│ ├── js/ # Frontend JavaScript
|
||||
│ ├── css/ # Stylesheets
|
||||
│ ├── vendor/ # Client-side libraries (Bootstrap, CodeMirror, etc.)
|
||||
│ └── .htaccess # Apache rewrite rules
|
||||
├── scripts/
|
||||
│ └── scan_uploads.php # CLI tool to rebuild metadata from filesystem
|
||||
├── uploads/ # User file storage (created at runtime)
|
||||
├── users/ # User data, permissions, tokens (created at runtime)
|
||||
└── metadata/ # File metadata, tags, shares, ACLs (created at runtime)
|
||||
```
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
#### 1. ACL System (src/lib/ACL.php)
|
||||
|
||||
The ACL class is the **single source of truth** for all permission checks. It manages folder-level permissions with inheritance:
|
||||
|
||||
- **Buckets**: owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
|
||||
- **Enforcement**: All controllers MUST call ACL methods (e.g., `ACL::canRead()`, `ACL::canWrite()`) before performing operations
|
||||
- **Storage**: Permissions stored in `metadata/folder_acl.json`
|
||||
- **Inheritance**: When a user is granted permissions on a folder, they typically have access to subfolders unless explicitly restricted
|
||||
|
||||
#### 2. Metadata System
|
||||
|
||||
FileRise stores metadata in JSON files rather than a database:
|
||||
|
||||
- **Per-folder metadata**: `metadata/{folder_key}_metadata.json`
|
||||
- Root folder: `root_metadata.json`
|
||||
- Subfolder "invoices/2025": `invoices-2025_metadata.json` (slashes/spaces replaced with hyphens)
|
||||
- **Global metadata**:
|
||||
- `users/users.txt` - User credentials (bcrypt hashed)
|
||||
- `users/userPermissions.json` - Per-user settings (encrypted)
|
||||
- `users/persistent_tokens.json` - "Remember me" tokens (encrypted)
|
||||
- `users/adminConfig.json` - Admin settings (encrypted)
|
||||
- `metadata/folder_acl.json` - All ACL rules
|
||||
- `metadata/folder_owners.json` - Folder ownership tracking
|
||||
|
||||
#### 3. Encryption
|
||||
|
||||
Sensitive data is encrypted using AES-256-CBC with the `PERSISTENT_TOKENS_KEY` environment variable:
|
||||
- Functions: `encryptData()` and `decryptData()` in config/config.php
|
||||
- Encrypted files: userPermissions.json, persistent_tokens.json, adminConfig.json, proLicense.json
|
||||
|
||||
#### 4. Session Management
|
||||
|
||||
- PHP sessions with configurable lifetime (default: 2 hours)
|
||||
- "Remember me" tokens stored separately with 30-day expiry
|
||||
- Session regeneration on login to prevent fixation attacks
|
||||
- Proxy authentication bypass mode (AUTH_BYPASS) for SSO integration
|
||||
|
||||
#### 5. WebDAV Integration
|
||||
|
||||
The WebDAV endpoint (`public/webdav.php`) uses sabre/dav with custom node classes:
|
||||
- `FileRiseFile` and `FileRiseDirectory` in `src/webdav/`
|
||||
- **All WebDAV operations respect ACL rules** via the same ACL class
|
||||
- Authentication via HTTP Basic Auth or proxy headers
|
||||
|
||||
#### 6. Pro Features
|
||||
|
||||
FileRise has a Pro version with additional features loaded dynamically:
|
||||
- Pro bundle located in `users/pro/` (configurable via FR_PRO_BUNDLE_DIR)
|
||||
- Bootstrap file: `users/pro/bootstrap_pro.php`
|
||||
- License validation sets FR_PRO_ACTIVE constant
|
||||
- Pro endpoints in `public/api/pro/`
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Testing ACL Changes
|
||||
|
||||
When modifying ACL logic:
|
||||
|
||||
1. Test with multiple user roles (admin, regular user, restricted user)
|
||||
2. Verify both UI and WebDAV respect the same rules
|
||||
3. Check inheritance behavior for nested folders
|
||||
4. Test edge cases: root folder, trash folder, special characters in paths
|
||||
|
||||
### Adding New API Endpoints
|
||||
|
||||
1. Create endpoint file in `public/api/` (e.g., `public/api/myFeature.php`)
|
||||
2. Include config: `require_once __DIR__ . '/../../config/config.php';`
|
||||
3. Check authentication: `if (empty($_SESSION['authenticated'])) { /* return 401 */ }`
|
||||
4. Perform ACL checks using `ACL::can*()` methods before operations
|
||||
5. Return JSON: `header('Content-Type: application/json'); echo json_encode($response);`
|
||||
|
||||
### Working with Metadata
|
||||
|
||||
Reading folder metadata:
|
||||
```php
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
$meta = FolderModel::getFolderMeta($folderKey); // e.g., "root" or "invoices/2025"
|
||||
```
|
||||
|
||||
Writing folder metadata:
|
||||
```php
|
||||
FolderModel::saveFolderMeta($folderKey, $metaArray);
|
||||
```
|
||||
|
||||
### Rebuilding Metadata from Filesystem
|
||||
|
||||
If files are added/removed outside FileRise:
|
||||
|
||||
```bash
|
||||
php scripts/scan_uploads.php
|
||||
```
|
||||
|
||||
This rebuilds all `*_metadata.json` files by scanning the uploads directory.
|
||||
|
||||
### Running in Docker
|
||||
|
||||
The Dockerfile and start.sh handle:
|
||||
- Setting PHP configuration (upload limits, timezone)
|
||||
- Running scan_uploads.php if SCAN_ON_START=true
|
||||
- Fixing permissions if CHOWN_ON_START=true
|
||||
- Starting Apache
|
||||
|
||||
Environment variables are processed in config/config.php (falls back to constants if not set).
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### File Organization
|
||||
|
||||
- Controllers handle HTTP requests and orchestrate business logic
|
||||
- Models handle data persistence (JSON file I/O)
|
||||
- ACL class is the **only** place for permission logic - never duplicate ACL checks
|
||||
- FS class provides filesystem utilities and path safety checks
|
||||
|
||||
### Security Requirements
|
||||
|
||||
- **Always validate user input** - use regex patterns from config.php (REGEX_FILE_NAME, REGEX_FOLDER_NAME)
|
||||
- **Always check ACLs** before file/folder operations
|
||||
- **Always use FS::safeReal()** to prevent path traversal via symlinks
|
||||
- **Never trust client-provided paths** - validate and sanitize all paths
|
||||
- **Use CSRF tokens** for state-changing operations (token in $_SESSION['csrf_token'])
|
||||
- **Sanitize output** when rendering user content (especially in previews)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Return appropriate HTTP status codes (401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error)
|
||||
- Log errors using `error_log()` for debugging
|
||||
- Return user-friendly JSON error messages
|
||||
|
||||
### Path Handling
|
||||
|
||||
- Use DIRECTORY_SEPARATOR for cross-platform compatibility
|
||||
- Always normalize folder keys with `ACL::normalizeFolder()`
|
||||
- Convert between absolute paths and folder keys consistently:
|
||||
- Absolute: `/var/www/uploads/invoices/2025/`
|
||||
- Folder key: `invoices/2025` (relative to uploads, forward slashes)
|
||||
- Root folder key: `root`
|
||||
|
||||
## Testing
|
||||
|
||||
FileRise does not currently have automated tests. When making changes:
|
||||
|
||||
1. Test manually in browser UI
|
||||
2. Test WebDAV operations (if applicable)
|
||||
3. Test with different user permission levels
|
||||
4. Test ACL inheritance behavior
|
||||
5. Check error cases (invalid input, insufficient permissions, missing files)
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions workflows (in `.github/workflows/`):
|
||||
- `ci.yml` - Basic CI checks
|
||||
- `release-on-version.yml` - Automated releases when version changes
|
||||
- `sync-changelog.yml` - Changelog synchronization
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **No ORM/framework**: This is vanilla PHP - all database operations are manual JSON file I/O
|
||||
- **Session-based auth**: Not JWT - sessions stored server-side, persistent tokens for "remember me"
|
||||
- **Metadata consistency**: If you modify files directly, run scan_uploads.php to rebuild metadata
|
||||
- **ACL is central**: Never bypass ACL checks - all file operations must go through ACL validation
|
||||
- **Encryption key**: PERSISTENT_TOKENS_KEY must be set in production (default is insecure)
|
||||
- **Pro features**: Some functionality is dynamically loaded from the Pro bundle - check FR_PRO_ACTIVE before calling Pro code
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- FileRise is designed to scale to **100k+ folders** in the sidebar tree
|
||||
- Metadata files are loaded on-demand (not all at once)
|
||||
- Large directory scans use scandir() with filtering - avoid recursive operations when possible
|
||||
- WebDAV PROPFIND operations should be optimized (limit depth)
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable PHP error reporting in development:
|
||||
```php
|
||||
ini_set('display_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
```
|
||||
|
||||
Check logs:
|
||||
- Apache error log: `/var/log/apache2/error.log` (or similar)
|
||||
- PHP error_log() output: check Docker logs with `docker logs filerise`
|
||||
|
||||
## Documentation
|
||||
|
||||
- Main docs: GitHub Wiki at https://github.com/error311/FileRise/wiki
|
||||
- API docs: Available at `/api.php` when logged in (Redoc interface)
|
||||
- OpenAPI spec: `openapi.json.dist`
|
||||
153
CONTRIBUTING.md
153
CONTRIBUTING.md
@@ -11,6 +11,7 @@ Thank you for your interest in contributing to FileRise! We appreciate your help
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Documentation](#documentation)
|
||||
- [Questions and Support](#questions-and-support)
|
||||
- [Adding New Language Translations](#adding-new-language-translations)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -25,7 +26,7 @@ Thank you for your interest in contributing to FileRise! We appreciate your help
|
||||
```
|
||||
|
||||
3. **Set Up a Local Environment**
|
||||
FileRise runs on a standard LAMP stack. Ensure you have PHP, Apache, and the necessary dependencies installed. For frontend development, Node.js may be required for build tasks if applicable.
|
||||
FileRise runs on a standard LAMP stack. Ensure you have PHP, Apache, and the necessary dependencies installed.
|
||||
|
||||
4. **Configuration**
|
||||
Copy any example configuration files (if provided) and adjust them as needed for your local setup.
|
||||
@@ -87,6 +88,156 @@ If you notice any areas in the documentation that need improvement or updating,
|
||||
|
||||
If you have any questions, ideas, or need support, please open an issue or join our discussion on [GitHub Discussions](https://github.com/error311/FileRise/discussions). We’re here to help and appreciate your contributions.
|
||||
|
||||
## Adding New Language Translations
|
||||
|
||||
FileRise supports internationalization (i18n) and localization via a central translation file (`i18n.js`). If you would like to contribute a new language translation, please follow these steps:
|
||||
|
||||
1. **Update `i18n.js`:**
|
||||
Open the `i18n.js` file located in the `js` directory. Within the `translations` object, add a new property using the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) as the key. Copy the structure from an existing language block and translate each key.
|
||||
|
||||
**Example (for German):**
|
||||
|
||||
```js
|
||||
de: {
|
||||
"please_log_in_to_continue": "Bitte melden Sie sich an, um fortzufahren.",
|
||||
"no_files_selected": "Keine Dateien ausgewählt.",
|
||||
"confirm_delete_files": "Sind Sie sicher, dass Sie {count} ausgewählte Datei(en) löschen möchten?",
|
||||
"element_not_found": "Element mit der ID \"{id}\" wurde nicht gefunden.",
|
||||
"search_placeholder": "Suche nach Dateien oder Tags...",
|
||||
"file_name": "Dateiname",
|
||||
"date_modified": "Änderungsdatum",
|
||||
"upload_date": "Hochladedatum",
|
||||
"file_size": "Dateigröße",
|
||||
"uploader": "Hochgeladen von",
|
||||
"enter_totp_code": "Geben Sie den TOTP-Code ein",
|
||||
"use_recovery_code_instead": "Verwenden Sie stattdessen den Wiederherstellungscode",
|
||||
"enter_recovery_code": "Geben Sie den Wiederherstellungscode ein",
|
||||
"editing": "Bearbeitung",
|
||||
"decrease_font": "A-",
|
||||
"increase_font": "A+",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"no_files_found": "Keine Dateien gefunden.",
|
||||
"switch_to_table_view": "Zur Tabellenansicht wechseln",
|
||||
"switch_to_gallery_view": "Zur Galerieansicht wechseln",
|
||||
"share_file": "Datei teilen",
|
||||
"set_expiration": "Ablauf festlegen:",
|
||||
"password_optional": "Passwort (optional):",
|
||||
"generate_share_link": "Freigabelink generieren",
|
||||
"shareable_link": "Freigabelink:",
|
||||
"copy_link": "Link kopieren",
|
||||
"tag_file": "Datei taggen",
|
||||
"tag_name": "Tagname:",
|
||||
"tag_color": "Tagfarbe:",
|
||||
"save_tag": "Tag speichern",
|
||||
"files_in": "Dateien in",
|
||||
"light_mode": "Heller Modus",
|
||||
"dark_mode": "Dunkler Modus",
|
||||
"upload_instruction": "Ziehen Sie Dateien/Ordner hierher oder klicken Sie auf 'Dateien auswählen'",
|
||||
"no_files_selected_default": "Keine Dateien ausgewählt",
|
||||
"choose_files": "Dateien auswählen",
|
||||
"delete_selected": "Ausgewählte löschen",
|
||||
"copy_selected": "Ausgewählte kopieren",
|
||||
"move_selected": "Ausgewählte verschieben",
|
||||
"tag_selected": "Ausgewählte taggen",
|
||||
"download_zip": "Zip herunterladen",
|
||||
"extract_zip": "Zip entpacken",
|
||||
"preview": "Vorschau",
|
||||
"edit": "Bearbeiten",
|
||||
"rename": "Umbenennen",
|
||||
"trash_empty": "Papierkorb ist leer.",
|
||||
"no_trash_selected": "Keine Elemente im Papierkorb für die Wiederherstellung ausgewählt.",
|
||||
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"logout": "Abmelden",
|
||||
"change_password": "Passwort ändern",
|
||||
"restore_text": "Wiederherstellen oder",
|
||||
"delete_text": "Papierkorbeinträge löschen",
|
||||
"restore_selected": "Ausgewählte wiederherstellen",
|
||||
"restore_all": "Alle wiederherstellen",
|
||||
"delete_selected_trash": "Ausgewählte löschen",
|
||||
"delete_all": "Alle löschen",
|
||||
"upload_header": "Dateien/Ordner hochladen",
|
||||
|
||||
// Folder Management keys:
|
||||
"folder_navigation": "Ordnernavigation & Verwaltung",
|
||||
"create_folder": "Ordner erstellen",
|
||||
"create_folder_title": "Ordner erstellen",
|
||||
"enter_folder_name": "Geben Sie den Ordnernamen ein",
|
||||
"cancel": "Abbrechen",
|
||||
"create": "Erstellen",
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
"rename_folder_title": "Ordner umbenennen",
|
||||
"rename_folder_placeholder": "Neuen Ordnernamen eingeben",
|
||||
"delete_folder": "Ordner löschen",
|
||||
"delete_folder_title": "Ordner löschen",
|
||||
"delete_folder_message": "Sind Sie sicher, dass Sie diesen Ordner löschen möchten?",
|
||||
"folder_help": "Ordnerhilfe",
|
||||
"folder_help_item_1": "Klicken Sie auf einen Ordner, um dessen Dateien anzuzeigen.",
|
||||
"folder_help_item_2": "Verwenden Sie [-] um zu minimieren und [+] um zu erweitern.",
|
||||
"folder_help_item_3": "Klicken Sie auf \"Ordner erstellen\", um einen Unterordner hinzuzufügen.",
|
||||
"folder_help_item_4": "Um einen Ordner umzubenennen oder zu löschen, wählen Sie ihn und klicken Sie auf die entsprechende Schaltfläche.",
|
||||
|
||||
// File List keys:
|
||||
"file_list_title": "Dateien in (Root)",
|
||||
"delete_files": "Dateien löschen",
|
||||
"delete_selected_files_title": "Ausgewählte Dateien löschen",
|
||||
"delete_files_message": "Sind Sie sicher, dass Sie die ausgewählten Dateien löschen möchten?",
|
||||
"copy_files": "Dateien kopieren",
|
||||
"copy_files_title": "Ausgewählte Dateien kopieren",
|
||||
"copy_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu kopieren:",
|
||||
"move_files": "Dateien verschieben",
|
||||
"move_files_title": "Ausgewählte Dateien verschieben",
|
||||
"move_files_message": "Wählen Sie einen Zielordner, um die ausgewählten Dateien zu verschieben:",
|
||||
"move": "Verschieben",
|
||||
"extract_zip_button": "Zip entpacken",
|
||||
"download_zip_title": "Ausgewählte Dateien als Zip herunterladen",
|
||||
"download_zip_prompt": "Geben Sie einen Namen für die Zip-Datei ein:",
|
||||
"zip_placeholder": "dateien.zip",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Anmelden",
|
||||
"remember_me": "Angemeldet bleiben",
|
||||
"login_oidc": "Mit OIDC anmelden",
|
||||
"basic_http_login": "HTTP-Basisauthentifizierung verwenden",
|
||||
|
||||
// Change Password keys:
|
||||
"change_password_title": "Passwort ändern",
|
||||
"old_password": "Altes Passwort",
|
||||
"new_password": "Neues Passwort",
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
|
||||
// Add User keys:
|
||||
"create_new_user_title": "Neuen Benutzer erstellen",
|
||||
"username": "Benutzername:",
|
||||
"password": "Passwort:",
|
||||
"grant_admin": "Admin-Rechte vergeben",
|
||||
"save_user": "Benutzer speichern",
|
||||
|
||||
// Remove User keys:
|
||||
"remove_user_title": "Benutzer entfernen",
|
||||
"select_user_remove": "Wählen Sie einen Benutzer zum Entfernen:",
|
||||
"delete_user": "Benutzer löschen",
|
||||
|
||||
// Rename File keys:
|
||||
"rename_file_title": "Datei umbenennen",
|
||||
"rename_file_placeholder": "Neuen Dateinamen eingeben",
|
||||
|
||||
// Custom Confirm Modal keys:
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"delete": "Löschen",
|
||||
"download": "Herunterladen",
|
||||
"upload": "Hochladen",
|
||||
"copy": "Kopieren",
|
||||
"extract": "Entpacken",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Dunkler Modus"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping to improve FileRise and happy coding!
|
||||
|
||||
145
Dockerfile
Normal file
145
Dockerfile
Normal file
@@ -0,0 +1,145 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
#############################
|
||||
# Source Stage – copy your FileRise app
|
||||
#############################
|
||||
FROM ubuntu:24.04 AS appsource
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* # clean up apt cache
|
||||
|
||||
RUN mkdir -p /var/www && rm -f /var/www/html/index.html
|
||||
COPY . /var/www
|
||||
|
||||
#############################
|
||||
# Composer Stage – install PHP dependencies
|
||||
#############################
|
||||
FROM composer:2 AS composer
|
||||
WORKDIR /app
|
||||
COPY --from=appsource /var/www/composer.json /var/www/composer.lock ./
|
||||
RUN composer install --no-dev --optimize-autoloader # production-ready autoloader
|
||||
|
||||
#############################
|
||||
# Final Stage – runtime image
|
||||
#############################
|
||||
FROM ubuntu:24.04
|
||||
LABEL by=error311
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
HOME=/root \
|
||||
LC_ALL=C.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 TERM=xterm \
|
||||
UPLOAD_MAX_FILESIZE=5G POST_MAX_SIZE=5G TOTAL_UPLOAD_SIZE=5G \
|
||||
PERSISTENT_TOKENS_KEY=default_please_change_this_key \
|
||||
PUID=99 PGID=100
|
||||
|
||||
# Install Apache, PHP, and required extensions
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
apache2 php php-json php-curl php-zip php-mbstring php-gd php-xml \
|
||||
ca-certificates curl git openssl && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* # slim down image
|
||||
|
||||
# Remap www-data to the PUID/PGID provided for safe bind mounts
|
||||
RUN set -eux; \
|
||||
if [ "$(id -u www-data)" != "${PUID}" ]; then usermod -u "${PUID}" www-data; fi; \
|
||||
if [ "$(id -g www-data)" != "${PGID}" ]; then groupmod -g "${PGID}" www-data 2>/dev/null || true; fi; \
|
||||
usermod -g "${PGID}" www-data
|
||||
|
||||
# Copy config, code, and vendor
|
||||
COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
||||
COPY --from=appsource /var/www /var/www
|
||||
COPY --from=composer /app/vendor /var/www/vendor
|
||||
|
||||
# ── ensure config/ is writable by www-data so sed -i can work ──
|
||||
RUN mkdir -p /var/www/config \
|
||||
&& chown -R www-data:www-data /var/www/config \
|
||||
&& chmod 750 /var/www/config
|
||||
|
||||
# Secure permissions: code read-only, only data dirs writable
|
||||
RUN chown -R root:www-data /var/www && \
|
||||
find /var/www -type d -exec chmod 755 {} \; && \
|
||||
find /var/www -type f -exec chmod 644 {} \; && \
|
||||
mkdir -p /var/www/public/uploads /var/www/users /var/www/metadata && \
|
||||
chown -R www-data:www-data /var/www/public/uploads /var/www/users /var/www/metadata && \
|
||||
chmod -R 775 /var/www/public/uploads /var/www/users /var/www/metadata # writable upload areas
|
||||
|
||||
# Apache site configuration
|
||||
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
||||
<VirtualHost *:80>
|
||||
# Global settings
|
||||
TraceEnable off
|
||||
KeepAlive On
|
||||
MaxKeepAliveRequests 100
|
||||
KeepAliveTimeout 5
|
||||
Timeout 60
|
||||
|
||||
ServerAdmin webmaster@localhost
|
||||
DocumentRoot /var/www/public
|
||||
|
||||
# Security headers for all responses
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
</IfModule>
|
||||
|
||||
# Compression
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
|
||||
</IfModule>
|
||||
|
||||
# Cache static assets
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType image/jpeg "access plus 1 month"
|
||||
ExpiresByType image/png "access plus 1 month"
|
||||
ExpiresByType text/css "access plus 1 week"
|
||||
ExpiresByType application/javascript "access plus 3 hour"
|
||||
</IfModule>
|
||||
|
||||
# Protect uploads directory
|
||||
Alias /uploads/ /var/www/uploads/
|
||||
<Directory "/var/www/uploads/">
|
||||
Options -Indexes
|
||||
AllowOverride None
|
||||
<IfModule mod_php7.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Public directory
|
||||
<Directory "/var/www/public">
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
DirectoryIndex index.html index.php
|
||||
</Directory>
|
||||
|
||||
# Deny access to hidden files
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<Files "api.php">
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
|
||||
</Files>
|
||||
|
||||
ErrorLog /var/www/metadata/log/error.log
|
||||
CustomLog /var/www/metadata/log/access.log combined
|
||||
</VirtualHost>
|
||||
EOF
|
||||
|
||||
# Enable required modules
|
||||
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
|
||||
|
||||
EXPOSE 80 443
|
||||
COPY start.sh /usr/local/bin/start.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
1
LICENSE
1
LICENSE
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 SeNS
|
||||
Copyright (c) 2025 FileRise
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
544
README.md
544
README.md
@@ -1,383 +1,293 @@
|
||||
# FileRise - Elevate your File Management
|
||||
# FileRise
|
||||
|
||||
**Demo link:** https://demo.filerise.net
|
||||
**UserName:** demo
|
||||
**Password:** demo
|
||||
Read only permissions but can view the interface.
|
||||
[](https://github.com/error311/FileRise)
|
||||
[](https://hub.docker.com/r/error311/filerise-docker)
|
||||
[](https://github.com/error311/filerise-docker/actions/workflows/main.yml)
|
||||
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
||||
[](https://demo.filerise.net)
|
||||
[](https://github.com/error311/FileRise/releases)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/7WN6f56X2e)
|
||||
[](https://github.com/sponsors/error311)
|
||||
[](https://ko-fi.com/error311)
|
||||
|
||||
**4/3/2025 Video demo:**
|
||||
**FileRise** is a modern, self-hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
|
||||
https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e
|
||||
- 💾 **Self-hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per-folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag-and-drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||
- 📊 **Storage / disk usage summary** – CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
|
||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||
- 👥 **Pro: user groups, client portals & storage explorer** – Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
|
||||
|
||||
**Dark mode:**
|
||||

|
||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
changelogs available here: <https://github.com/error311/FileRise-docker/>
|
||||

|
||||
|
||||
FileRise is a lightweight, secure, self-hosted web application for uploading, syntax-highlight editing, drag & drop file management, and more. Built with an Apache/PHP backend and a modern JavaScript (ES6 modules) frontend, it offers a responsive and dynamic interface designed to simplify file handling. As an alternative to solutions like FileGator, TinyFileManager, or ProjectSend, FileRise provides an easy-to-set-up experience ideal for document management, image galleries, firmware hosting, and other file-intensive applications.
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## Quick links
|
||||
|
||||
- **Multiple File/Folder Uploads with Progress (Resumable.js Integration):**
|
||||
- Users can effortlessly upload multiple files and folders simultaneously by either selecting them through the file picker or dragging and dropping them directly into the interface.
|
||||
- **Chunked Uploads:** Files are uploaded in configurable chunks (default set as 3 MB) to efficiently handle large files.
|
||||
- **Pause, Resume, and Retry:** Uploads can be paused and resumed at any time, with support for retrying failed chunks.
|
||||
- **Real-Time Progress:** Each file shows an individual progress bar that displays percentage complete and upload speed.
|
||||
- **File & Folder Grouping:** When many files are dropped, files are automatically grouped into a scrollable wrapper, ensuring the interface remains clean.
|
||||
- **Secure Uploads:** All uploads integrate CSRF token validation and other security checks.
|
||||
|
||||
- **Built-in File Editing & Renaming:**
|
||||
- Text-based files (e.g., .txt, .html, .js) can be opened and edited in a modal window using CodeMirror for:
|
||||
- Syntax highlighting
|
||||
- Line numbering
|
||||
- Adjustable font sizes
|
||||
- Files can be renamed directly through the interface.
|
||||
- The renaming functionality now supports names with parentheses and checks for duplicate names, automatically generating a unique name (e.g., appending “ (1)”) when needed.
|
||||
- Folder-specific metadata is updated accordingly.
|
||||
- **Enhanced File Editing Check:** Files with a Content-Length of 0 KB are now allowed to be edited.
|
||||
|
||||
- **Built-in File Preview:**
|
||||
- Users can quickly preview images, videos, audio and PDFs directly in modal popups without leaving the page.
|
||||
- The preview modal supports inline display of images (with proper scaling) and videos with playback controls.
|
||||
- Navigation (prev/next) within image previews is supported for a seamless browsing experience.
|
||||
|
||||
- **Gallery (Grid) View:**
|
||||
- In addition to the traditional table view, users can toggle to a gallery view that arranges image thumbnails in a grid layout.
|
||||
- The gallery view offers multiple column options (e.g., 3, 4, or 5 columns) so that users can choose the layout that best fits their screen.
|
||||
- Action buttons (Download, Edit, Rename, Share) appear beneath each thumbnail for quick access.
|
||||
|
||||
- **Batch Operations (Delete/Copy/Move/Download/Extract Zip):**
|
||||
- **Delete Files:** Delete multiple files at once.
|
||||
- **Copy Files:** Copy selected files to another folder with a unique-naming feature to prevent overwrites.
|
||||
- **Move Files:** Move selected files to a different folder, automatically generating a unique filename if needed to avoid data loss.
|
||||
- **Download Files as ZIP:** Download selected files as a ZIP archive. Users can specify a custom name for the ZIP file via a modal dialog.
|
||||
- **Extract Zip:** When one or more ZIP files are selected, users can extract the archive(s) directly into the current folder.
|
||||
- **Drag & Drop (File Movement):** Easily move files by selecting them from the file list and dragging them onto your desired folder in the folder tree or breadcrumb. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
|
||||
- **Enhanced Context Menu & Keyboard Shortcuts:**
|
||||
- **Right-Click Context Menu:**
|
||||
- A custom context menu appears on right-clicking within the file list.
|
||||
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
|
||||
- When exactly one file is selected, additional options (Preview, Edit [if editable], Rename, and Tag File) are available.
|
||||
- **Keyboard Shortcut for Deletion:**
|
||||
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
|
||||
|
||||
- **File Tagging and Global Tag Management:**
|
||||
- **Context Menu Tagging:**
|
||||
- Single-file tagging: “Tag File” option in the right-click menu opens a modal to add a tag (with name and color) to the file.
|
||||
- Multi-file tagging: When multiple files are selected, a “Tag Selected” option opens a multi‑file tagging modal to apply the same tag to all selected files.
|
||||
- **Tagging Modals & Custom Dropdown:**
|
||||
- Dedicated modals provide an interface for adding and updating tags.
|
||||
- A custom dropdown in each modal displays available global tags with a colored preview and a remove icon.
|
||||
- **Global Tag Store:**
|
||||
- Tags are stored globally (persisted in a JSON file) for reuse across files and sessions.
|
||||
- New tags added to any file are automatically added to the global store.
|
||||
- Users can remove a global tag directly from the dropdown, which removes it from the available tag list for all files.
|
||||
- **Unified Search Filtering:**
|
||||
- The single search box now filters files based on both file names and tag names (case‑insensitive).
|
||||
|
||||
- **Folder Management:**
|
||||
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
|
||||
- A dynamic folder tree in the UI allows users to navigate directories easily, with real-time updates.
|
||||
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), updated with operations like copy/move/rename.
|
||||
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder; supports drag & drop for moving files.
|
||||
- **Folder Manager Context Menu:**
|
||||
- Right-clicking on a folder brings up a custom context menu with options for creating, renaming, and deleting folders.
|
||||
- **Keyboard Shortcut for Folder Deletion:**
|
||||
- A global key listener (Delete/Backspace) triggers folder deletion with safeguards to prevent deletion of the root folder.
|
||||
|
||||
- **Sorting & Pagination:**
|
||||
- Files can be sorted by name, modified date, upload date, file size, or uploader.
|
||||
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” buttons.
|
||||
|
||||
- **Share Link Functionality:**
|
||||
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and 1 day) and optional password protection.
|
||||
- Share links are stored in a JSON file with details including folder, file, expiration timestamp, and hashed password.
|
||||
- The share endpoint validates tokens, expiration, and password before serving files (or forcing downloads).
|
||||
- The share URL is configurable via environment variables or auto-detected from the server.
|
||||
|
||||
- **User Authentication & Management:**
|
||||
- Secure, session-based authentication protects the file manager.
|
||||
- Admin users can add or remove users through the interface.
|
||||
- Passwords are hashed using PHP’s `password_hash()` for security.
|
||||
- All state-changing endpoints include CSRF token validation.
|
||||
- Password change functionality is supported for all users.
|
||||
- Basic Auth is available for login.
|
||||
- **Persistent Login (Remember Me) with Encrypted Tokens:**
|
||||
- Users can remain logged in across sessions securely.
|
||||
- Persistent tokens are encrypted using AES‑256‑CBC before being stored in a JSON file.
|
||||
- On auto-login, tokens are decrypted on the server to re-establish user sessions without re-authentication.
|
||||
|
||||
- **Responsive, Dynamic & Persistent UI:**
|
||||
- The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices.
|
||||
- Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads.
|
||||
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth, customized user experience.
|
||||
|
||||
- **Dark Mode/Light Mode:**
|
||||
- The application automatically adapts to the operating system’s theme preference by default, with a manual toggle available.
|
||||
- Dark mode provides a darker background with lighter text, and UI elements (including the CodeMirror editor) are adjusted for optimal readability in low-light conditions.
|
||||
- Light mode maintains a bright interface suitable for well-lit environments.
|
||||
|
||||
- **Server & Security Enhancements:**
|
||||
- Apache (or .htaccess) configurations disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized file browsing.
|
||||
- Direct access to sensitive files (e.g., `users.txt`) is restricted via .htaccess rules.
|
||||
- A proxy download mechanism (via endpoints like `download.php` and `downloadZip.php`) routes all file downloads through PHP, ensuring session and CSRF token validation before file access.
|
||||
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments.
|
||||
|
||||
- **Trash Management with Restore & Delete:**
|
||||
- **Trash Storage & Metadata:**
|
||||
- Deleted files are moved to a designated “Trash” folder rather than being immediately removed.
|
||||
- Metadata is stored in a JSON file (`trash.json`) that records:
|
||||
- Original folder and file name
|
||||
- Timestamp when the file was trashed
|
||||
- Uploader information (and optionally who deleted it)
|
||||
- Additional metadata (e.g., file type)
|
||||
- **Restore Functionality:**
|
||||
- Admins can view trashed files in a modal and restore individual or all files back to their original location (with conflict checks).
|
||||
- **Delete Functionality:**
|
||||
- Users can permanently delete trashed files via:
|
||||
- **Delete Selected:** Remove specific files from the Trash and update `trash.json`.
|
||||
- **Delete All:** Permanently remove every file from the Trash after confirmation.
|
||||
- **Auto-Purge Mechanism:**
|
||||
- The system automatically purges files in the Trash older than three days, managing storage and preventing accumulation of outdated files.
|
||||
- **Trash UI:**
|
||||
- The trash modal displays file name, uploader/deleter, and trashed date/time.
|
||||
- Material icons with tooltips represent restore and delete actions.
|
||||
|
||||
- **Drag & Drop Cards with Dedicated Drop Zones:**
|
||||
- **Sidebar Drop Zone:**
|
||||
- Cards (e.g., upload or folder management) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
|
||||
- The sidebar drop zone expands dynamically to accept drops anywhere within its visual area.
|
||||
- **Top Bar Drop Zone:**
|
||||
- A top drop zone is available for reordering or managing cards quickly.
|
||||
- Dragging a card to the top drop zone provides immediate visual feedback, ensuring a fluid and customizable workflow.
|
||||
- **Header Drop Zone with State Preservation:**
|
||||
- Cards can be dragged into the header drop zone, where they are represented by a compact material icon.
|
||||
- **State Preservation:** Instead of removing the card from the DOM, the original card is moved into a hidden container. This ensures that dynamic features (such as the folder tree in the Folder Management card or file selection in the Upload card) remain fully initialized and retain their state on page refresh.
|
||||
- **Modal Display:** When the user interacts (via hover or click) with the header icon, the card is temporarily moved into a modal overlay for full interaction. When the modal is closed, the card is returned to the hidden container, keeping its state persistent.
|
||||
- **Seamless Interaction:**
|
||||
- Both drop zones support smooth drag-and-drop interactions with animations and pointer event adjustments, ensuring reliable card placement regardless of screen position.
|
||||
|
||||
## 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
|
||||
|
||||
- **Flexible Authentication:**
|
||||
- Supports multiple authentication methods including Form-based Login, Basic Auth, OpenID Connect (OIDC), and TOTP-based Two-Factor Authentication.
|
||||
- Ensures continuous secure access by allowing administrators to disable only two of the available login options at any time.
|
||||
|
||||
- **Secure OIDC Authentication:**
|
||||
- Seamlessly integrates with OIDC providers (e.g., Keycloak, Okta).
|
||||
- Provides admin-configurable OIDC settings—including Provider URL, Client ID, Client Secret, and Redirect URI.
|
||||
- Stores all sensitive configurations in an encrypted JSON file.
|
||||
|
||||
- **TOTP Two-Factor Authentication:**
|
||||
- Enhances security by integrating Time-based One-Time Password (TOTP) functionality.
|
||||
- The new User Panel automatically displays the TOTP setup modal when users enable TOTP, presenting a QR code for easy configuration in authenticator apps.
|
||||
- Administrators can customize a global OTPAuth URL template for consistent TOTP provisioning across accounts.
|
||||
|
||||
- **Dynamic Admin Panel:**
|
||||
- Features an intuitive interface with Material Icons for quick recognition and access.
|
||||
- Allows administrators to manage authentication settings, user management, and login methods in real time.
|
||||
- Includes real-time validation that prevents the accidental disabling of all authentication methods simultaneously.
|
||||
- **User Permissions Options:**
|
||||
- *Folder Only* gives user their own root folder.
|
||||
- *Read Only* makes it so the user can only read the files.
|
||||
- *Disable Upload* prevents file uploads.
|
||||
- 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
|
||||
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
|
||||
- [Features overview](https://github.com/error311/FileRise/wiki/Features)
|
||||
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
||||
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
|
||||
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
## 1. What FileRise does
|
||||
|
||||
**Admin Panel:**
|
||||

|
||||
FileRise turns a folder on your server into a **web-based file explorer** with:
|
||||
|
||||
**Light mode:**
|
||||

|
||||
- Folder tree + breadcrumbs for fast navigation
|
||||
- Multi-file/folder drag-and-drop uploads
|
||||
- Move / copy / rename / delete / extract ZIP
|
||||
- Public share links (optionally password-protected & expiring)
|
||||
- Tagging and search by name, tag, uploader, and content
|
||||
- Trash with restore/purge
|
||||
- Inline previews (images, audio, video, PDF) and a built-in code editor
|
||||
|
||||
**Light mode default:**
|
||||

|
||||
|
||||
**Dark editor:**
|
||||

|
||||
|
||||
**Light preview**
|
||||

|
||||
|
||||
**Restore or Delete Trash:**
|
||||

|
||||
|
||||
**Dark TOTP Setup:**
|
||||

|
||||
|
||||
**Gallery view:**
|
||||

|
||||
|
||||
**iphone screenshots:**
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-iphone.png" width="45%">
|
||||
<img src="https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/light-preview-iphone.png" width="45%">
|
||||
</p>
|
||||
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
## 2. Install (Docker – recommended)
|
||||
|
||||
### Manual Installation
|
||||
The easiest way to run FileRise is the official Docker image.
|
||||
|
||||
1. **Clone or Download the Repository:**
|
||||
- **Clone:**
|
||||
### Option A – Quick start (docker run)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
```bash
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="10G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
- **Download:**
|
||||
Download the latest release from the GitHub releases page and extract it into your desired directory.
|
||||
Then visit:
|
||||
|
||||
2. **Deploy to Your Web Server:**
|
||||
- Place the project files in your Apache web directory (e.g., `/var/www/html`).
|
||||
- Ensure PHP 8.1+ is installed along with the required extensions (`php-json`, `php-curl`, `php-zip`, etc.).
|
||||
```text
|
||||
http://your-server-ip:8080
|
||||
```
|
||||
|
||||
3. **Install Composer Dependencies (Required for OIDC Support):**
|
||||
- Install Composer if you haven't already ([Installation Guide](https://getcomposer.org/download/)).
|
||||
- Navigate to the project directory and run:
|
||||
On first launch you’ll be guided through creating the **initial admin user**.
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you don’t want a recursive `chown` on every start.
|
||||
|
||||
- This step will install necessary dependencies like `jumbojett/openid-connect-php` and `phpseclib/phpseclib`.
|
||||
|
||||
4. **Directory Setup & Permissions:**
|
||||
- Create the following directories if they do not exist, and set appropriate permissions:
|
||||
- `uploads/` – for file storage.
|
||||
- `users/` – to store `users.txt` (user authentication data).
|
||||
- `metadata/` – for storing `file_metadata.json` and other metadata.
|
||||
- Example commands:
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/uploads /var/www/users /var/www/metadata
|
||||
chmod -R 775 /var/www/uploads /var/www/users /var/www/metadata
|
||||
```
|
||||
|
||||
5. **Configure Apache:**
|
||||
- Ensure that directory indexing is disabled (using `Options -Indexes` in your `.htaccess` or Apache configuration).
|
||||
- Make sure the Apache configuration allows URL rewriting if needed.
|
||||
|
||||
6. **Configuration File:**
|
||||
- Open `config.php` and adjust the following constants as necessary:
|
||||
- `BASE_URL`: Set this to your web app’s base URL.
|
||||
- `UPLOAD_DIR`: Adjust the directory path for uploads.
|
||||
- `TIMEZONE`: Set to your preferred timezone.
|
||||
- `TOTAL_UPLOAD_SIZE`: Ensure it matches PHP’s `upload_max_filesize` and `post_max_size` settings in your `php.ini`.
|
||||
|
||||
### Initial Setup Instructions
|
||||
|
||||
- **First Launch Admin Setup:**
|
||||
On first launch, if no users exist, the application will enter a setup mode. You will be prompted to create an admin user. This is handled automatically by the application (e.g., via a “Create Admin” form).
|
||||
**Note:** No default credentials are provided. You must create the first admin account to log in and manage additional users.
|
||||
> ⚠️ **Uploads folder recommendation**
|
||||
>
|
||||
> It’s strongly recommended to bind `/var/www/uploads` to a **dedicated folder**
|
||||
> (for example `~/filerise/uploads` or `/mnt/user/appdata/FileRise/uploads`),
|
||||
> not the root of a huge media share.
|
||||
>
|
||||
> If you really want FileRise to sit “on top of” an existing share, use a
|
||||
> subfolder (e.g. `/mnt/user/media/filerise_root`) instead of the share root,
|
||||
> so scans and permission changes stay scoped to that folder.
|
||||
|
||||
---
|
||||
|
||||
## Docker Usage
|
||||
### Option B – docker-compose.yml
|
||||
|
||||
For users who prefer containerization, a Docker image is available.
|
||||
```yaml
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "America/New_York"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
SCAN_ON_START: "true" # auto-index existing files on startup
|
||||
CHOWN_ON_START: "true" # fix permissions on uploads/users/metadata on startup
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
**Note:** The Docker image already includes Composer dependencies pre-installed (including OIDC support).
|
||||
Bring it up with:
|
||||
|
||||
### Quickstart
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. **Pull the Docker Image:**
|
||||
---
|
||||
|
||||
### Common environment variables
|
||||
|
||||
| Variable | Required | Example | What it does |
|
||||
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
|
||||
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
|
||||
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
|
||||
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
|
||||
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
|
||||
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
|
||||
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
|
||||
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
|
||||
|
||||
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
|
||||
> (currently `m/d/y h:iA`).
|
||||
> 🗂 **Using an existing folder tree**
|
||||
>
|
||||
> - Point `/var/www/uploads` at the folder you want FileRise to manage.
|
||||
> - Set `SCAN_ON_START="true"` on the first run to index existing files, then
|
||||
> usually set it to `"false"` so the container doesn’t rescan on every restart.
|
||||
> - `CHOWN_ON_START="true"` is handy on first run to fix permissions. If you map
|
||||
> a large share or already manage ownership yourself, set it to `"false"` to
|
||||
> avoid recursive `chown` on every start.
|
||||
>
|
||||
> Volumes:
|
||||
> - `/var/www/uploads` – your actual files
|
||||
> - `/var/www/users` – user & pro jsons
|
||||
> - `/var/www/metadata` – tags, search index, share links, etc.
|
||||
|
||||
**More Docker / orchestration options (Unraid, Portainer, k8s, reverse proxy, etc.)**
|
||||
- [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
||||
- [Nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
||||
- [FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
||||
- [Kubernetes / k8s deployment](https://github.com/error311/FileRise/wiki/Kubernetes---k8s-deployment)
|
||||
- Portainer templates: add this URL in Portainer → Settings → App Templates:
|
||||
`https://raw.githubusercontent.com/error311/filerise-portainer-templates/refs/heads/main/templates.json`
|
||||
- See also the Docker repo: [error311/filerise-docker](https://github.com/error311/filerise-docker)
|
||||
|
||||
---
|
||||
|
||||
## 3. Manual install (PHP web server)
|
||||
|
||||
Prefer bare-metal or your own stack? FileRise is just PHP + a few extensions.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- PHP **8.3+**
|
||||
- Web server (Apache / Nginx / Caddy + PHP-FPM)
|
||||
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
||||
- No database required
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Clone or download FileRise into your web root:
|
||||
|
||||
```bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
|
||||
macos M series:
|
||||
2. Create data directories and set permissions:
|
||||
|
||||
```bash
|
||||
docker pull --platform linux/x86_64 error311/filerise-docker:latest
|
||||
cd FileRise
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # adjust for your web user
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
2. **Run the Container:**
|
||||
3. (Optional) Install PHP dependencies with Composer:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 80:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-v /path/to/your/uploads:/var/www/uploads \
|
||||
-v /path/to/your/users:/var/www/users \
|
||||
-v /path/to/your/metadata:/var/www/metadata \
|
||||
--name FileRise \
|
||||
error311/filerise-docker:latest
|
||||
composer install
|
||||
```
|
||||
|
||||
3. **Using Docker Compose:**
|
||||
4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
|
||||
- Apache: allow `.htaccess` or copy its rules into your vhost.
|
||||
- Nginx/Caddy: mirror the basic protections (no directory listing, block sensitive files).
|
||||
|
||||
Create a docker-compose.yml file with the following content:
|
||||
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: error311/filerise-docker:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
TIMEZONE: "America/New_York"
|
||||
TOTAL_UPLOAD_SIZE: "5G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
volumes:
|
||||
- /path/to/your/uploads:/var/www/uploads
|
||||
- /path/to/your/users:/var/www/users
|
||||
- /path/to/your/metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
**Then start the container with:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Guidance
|
||||
## 4. WebDAV & ONLYOFFICE (optional)
|
||||
|
||||
The `config.php` file contains several key constants that may need adjustment for your deployment:
|
||||
### WebDAV
|
||||
|
||||
- **BASE_URL:**
|
||||
Set to the URL where your application is hosted (e.g., `http://yourdomain.com/uploads/`).
|
||||
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
|
||||
|
||||
- **UPLOAD_DIR, USERS_DIR, META_DIR:**
|
||||
Define the directories for uploads, user data, and metadata. Adjust these to match your server environment or Docker volume mounts.
|
||||
- **macOS Finder** – Go → Connect to Server → `https://your-host/webdav.php/`
|
||||
- **Windows File Explorer** – Map Network Drive → `https://your-host/webdav.php/`
|
||||
- **Linux (GVFS/Nautilus)** – `dav://your-host/webdav.php/`
|
||||
- Clients like **Cyberduck**, **WinSCP**, etc.
|
||||
|
||||
- **TIMEZONE & DATE_TIME_FORMAT:**
|
||||
Set according to your regional settings.
|
||||
WebDAV operations honor the same ACLs as the web UI.
|
||||
|
||||
- **TOTAL_UPLOAD_SIZE:**
|
||||
Defines the maximum upload size (default is `5G`). Ensure that PHP’s `upload_max_filesize` and `post_max_size` in your `php.ini` are consistent with this setting. The startup script (`start.sh`) updates PHP limits at runtime based on this value.
|
||||
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
|
||||
- **Environment Variables (Docker):**
|
||||
The Docker image supports overriding configuration via environment variables. For example, you can set `SECURE`, `SHARE_URL`, `PERSISTENT_TOKENS_KEY` and port settings via the container’s environment.
|
||||
### ONLYOFFICE integration
|
||||
|
||||
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view-only).
|
||||
|
||||
Configure it in **Admin → ONLYOFFICE**:
|
||||
|
||||
- Enable ONLYOFFICE
|
||||
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||
- Configure a shared JWT secret
|
||||
- Copy the suggested Content-Security-Policy header into your reverse proxy
|
||||
|
||||
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
|
||||
---
|
||||
|
||||
## Additional Information
|
||||
## 5. Security & updates
|
||||
|
||||
- **Security:**
|
||||
All state-changing endpoints use CSRF token validation. Ensure that sessions and tokens are correctly configured as per your deployment environment.
|
||||
- FileRise is actively maintained and has published security advisories.
|
||||
- See **SECURITY.md** and GitHub Security Advisories for details.
|
||||
- To upgrade:
|
||||
- **Docker:** `docker pull error311/filerise-docker:latest` and recreate the container with the same volumes.
|
||||
- **Manual:** replace app files with the latest release (keep `uploads/`, `users/`, `metadata/`, and your config).
|
||||
|
||||
- **Permissions:**
|
||||
Both manual and Docker installations include steps to ensure that file and directory permissions are set correctly for the web server to read and write as needed.
|
||||
|
||||
- **Logging & Troubleshooting:**
|
||||
Check Apache logs (located in `/var/log/apache2/`) for troubleshooting any issues during deployment or operation.
|
||||
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
## 6. Community, support & contributing
|
||||
|
||||
We welcome contributions! Please check out our [Contributing Guidelines](CONTRIBUTING.md) before getting started.
|
||||
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
|
||||
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
|
||||
|
||||
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
|
||||
|
||||
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||
- ☕ [Ko-fi](https://ko-fi.com/error311)
|
||||
|
||||
---
|
||||
|
||||
## 7. License & third-party code
|
||||
|
||||
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||
|
||||
It bundles a small set of well-known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||
All third-party code remains under its original licenses.
|
||||
|
||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||
|
||||
## 8. Press
|
||||
|
||||
- [Heise / iX Magazin – “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
|
||||
- [Heise / iX Magazin – “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)
|
||||
|
||||
61
SECURITY.md
Normal file
61
SECURITY.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We provide security fixes for the latest minor release line.
|
||||
|
||||
| Version | Supported |
|
||||
|----------|-----------|
|
||||
| v1.5.x | ✅ |
|
||||
| ≤ v1.4.x | ❌ |
|
||||
|
||||
> Known issues in ≤ v1.4.x are fixed in **v1.5.0** and later.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue.** Use one of the private channels below:
|
||||
|
||||
1) **GitHub Security Advisory (preferred)**
|
||||
Open a private report here: <https://github.com/error311/FileRise/security/advisories/new>
|
||||
|
||||
2) **Email**
|
||||
Send details to **<security@filerise.net>** with subject: `[FileRise] Security Vulnerability Report`.
|
||||
|
||||
### What to include
|
||||
|
||||
- Affected versions (e.g., v1.4.0), component/endpoint, and impact
|
||||
- Reproduction steps / PoC
|
||||
- Any logs, screenshots, or crash traces
|
||||
- Safe test scope used (see below)
|
||||
|
||||
If you’d like encrypted comms, ask for our PGP key in your first email.
|
||||
|
||||
## Coordinated Disclosure
|
||||
|
||||
- **Acknowledgement:** within **48 hours**
|
||||
- **Triage & initial assessment:** within **7 days**
|
||||
- **Fix target:** within **30 days** for high-severity issues (may vary by complexity)
|
||||
- **CVE & advisory:** we publish a GitHub Security Advisory and request a CVE when appropriate.
|
||||
We notify the reporter before public disclosure and credit them (unless they prefer to remain anonymous).
|
||||
|
||||
## Safe-Harbor / Rules of Engagement
|
||||
|
||||
We support good-faith research. Please:
|
||||
|
||||
- Avoid privacy violations, data exfiltration, and service disruption (no DoS, spam, or brute-forcing)
|
||||
- Don’t access other users’ data beyond what’s necessary to demonstrate the issue
|
||||
- Don’t run automated scans against production installs you don’t own
|
||||
- Follow applicable laws and make a good-faith effort to respect data and availability
|
||||
|
||||
If you follow these guidelines, we won’t pursue or support legal action.
|
||||
|
||||
## Published Advisories
|
||||
|
||||
- **GHSA-6p87-q9rh-95wh** — ≤ **1.3.15**: Improper ownership/permission validation allowed cross-tenant file operations.
|
||||
- **GHSA-jm96-2w52-5qjj** — **v1.4.0**: Insecure folder visibility via name-based mapping and incomplete ACL checks.
|
||||
|
||||
Both are fixed in **v1.5.0** (ACL hardening). Thanks to **[@kiwi865](https://github.com/kiwi865)** for responsible disclosure.
|
||||
|
||||
## Questions
|
||||
|
||||
General security questions: **<admin@filerise.net>**
|
||||
47
THIRD_PARTY.md
Normal file
47
THIRD_PARTY.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Third-Party Notices
|
||||
|
||||
FileRise bundles the following third‑party assets. Each item lists the project, version, typical on-disk location in this repo, and its license.
|
||||
|
||||
If you believe any attribution is missing or incorrect, please open an issue.
|
||||
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
- **Roboto (wght 400/500)** — Google Fonts
|
||||
**License:** Apache License 2.0
|
||||
**Files:** `public/css/vendor/roboto.css`, `public/fonts/roboto/*.woff2`
|
||||
|
||||
- **Material Icons (ligature font)** — Google Fonts
|
||||
**License:** Apache License 2.0
|
||||
**Files:** `public/css/vendor/material-icons.css`, `public/fonts/material-icons/*.woff2`
|
||||
|
||||
> Google fonts/icons © Google. Licensed under Apache 2.0. See `licenses/apache-2.0.txt`.
|
||||
|
||||
---
|
||||
|
||||
## CSS / JS Libraries (vendored)
|
||||
|
||||
- **Bootstrap 4.5.2** — MIT License
|
||||
**Files:** `public/vendor/bootstrap/4.5.2/bootstrap.min.css`
|
||||
|
||||
- **CodeMirror 5.65.5** — MIT License
|
||||
**Files:** `public/vendor/codemirror/5.65.5/*`
|
||||
|
||||
- **DOMPurify 2.4.0** — Apache License 2.0
|
||||
**Files:** `public/vendor/dompurify/2.4.0/purify.min.js`
|
||||
|
||||
- **Fuse.js 6.6.2** — Apache License 2.0
|
||||
**Files:** `public/vendor/fuse/6.6.2/fuse.min.js`
|
||||
|
||||
- **Resumable.js 1.1.0** — MIT License
|
||||
**Files:** `public/vendor/resumable/1.1.0/resumable.min.js`
|
||||
|
||||
- **ReDoc (redoc.standalone.js)** — MIT License
|
||||
**Files:** `public/vendor/redoc/redoc.standalone.js`
|
||||
**Notes:** Self-hosted to comply with `script-src 'self'` CSP.
|
||||
|
||||
> MIT-licensed code: see `licenses/mit.txt`.
|
||||
> Apache-2.0–licensed code: see `licenses/apache-2.0.txt`.
|
||||
|
||||
---
|
||||
86
addUser.php
86
addUser.php
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Determine if we are in setup mode:
|
||||
// - Query parameter setup=1 is passed
|
||||
// - And users.txt is either missing or empty (zero bytes or trimmed content is empty)
|
||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
|
||||
// Allow initial admin creation without session checks.
|
||||
$setupMode = true;
|
||||
} else {
|
||||
$setupMode = false;
|
||||
// In non-setup mode, check CSRF token and require admin privileges.
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Get input data from JSON.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$newUsername = trim($data["username"] ?? "");
|
||||
$newPassword = trim($data["password"] ?? "");
|
||||
|
||||
// In setup mode, force the new user to be admin.
|
||||
if ($setupMode) {
|
||||
$isAdmin = "1";
|
||||
} else {
|
||||
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; // "1" for admin, "0" for regular user.
|
||||
}
|
||||
|
||||
// Validate input.
|
||||
if (!$newUsername || !$newPassword) {
|
||||
echo json_encode(["error" => "Username and password required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate username using preg_match (allow letters, numbers, underscores, dashes, and spaces).
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $newUsername)) {
|
||||
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure users.txt exists.
|
||||
if (!file_exists($usersFile)) {
|
||||
file_put_contents($usersFile, '');
|
||||
}
|
||||
|
||||
// Check if username already exists.
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($existingUsers as $line) {
|
||||
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
|
||||
if ($newUsername === $storedUser) {
|
||||
echo json_encode(["error" => "User already exists"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
$hashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
|
||||
// Prepare new user line.
|
||||
$newUserLine = $newUsername . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||
|
||||
// In setup mode, overwrite users.txt; otherwise, append to it.
|
||||
if ($setupMode) {
|
||||
file_put_contents($usersFile, $newUserLine);
|
||||
} else {
|
||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
||||
}
|
||||
|
||||
echo json_encode(["success" => "User added successfully"]);
|
||||
?>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
assets/logo.svg
BIN
assets/logo.svg
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
412
auth.js
412
auth.js
@@ -1,412 +0,0 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, showToast, attachEnterKeyListener, showCustomConfirmModal } from './domUtils.js';
|
||||
import { loadFileList, renderFileTable, displayFilePreview, initFileActions } from './fileManager.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import {
|
||||
openTOTPLoginModal,
|
||||
openUserPanel,
|
||||
openTOTPModal,
|
||||
closeTOTPModal,
|
||||
openAdminPanel,
|
||||
closeAdminPanel,
|
||||
setLastLoginData
|
||||
} from './authModals.js';
|
||||
|
||||
// Production OIDC configuration (override via API as needed)
|
||||
const currentOIDCConfig = {
|
||||
providerUrl: "https://your-oidc-provider.com",
|
||||
clientId: "YOUR_CLIENT_ID",
|
||||
clientSecret: "YOUR_CLIENT_SECRET",
|
||||
redirectUri: "https://yourdomain.com/auth.php?oidc=callback",
|
||||
globalOtpauthUrl: ""
|
||||
};
|
||||
window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
/* ----------------- Utility Functions ----------------- */
|
||||
function updateItemsPerPageSelect() {
|
||||
const selectElem = document.querySelector(".form-control.bottom-select");
|
||||
if (selectElem) {
|
||||
selectElem.value = localStorage.getItem("itemsPerPage") || "10";
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
|
||||
const authForm = document.getElementById("authForm");
|
||||
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block";
|
||||
const basicAuthLink = document.querySelector("a[href='login_basic.php']");
|
||||
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
|
||||
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
|
||||
if (oidcLoginBtn) oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block";
|
||||
}
|
||||
|
||||
function updateLoginOptionsUIFromStorage() {
|
||||
updateLoginOptionsUI({
|
||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
|
||||
});
|
||||
}
|
||||
|
||||
function loadAdminConfigFunc() {
|
||||
return fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/FileRise?issuer=FileRise");
|
||||
updateLoginOptionsUIFromStorage();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.setItem("disableFormLogin", "false");
|
||||
localStorage.setItem("disableBasicAuth", "false");
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
localStorage.setItem("globalOtpauthUrl", "otpauth://totp/FileRise?issuer=FileRise");
|
||||
updateLoginOptionsUIFromStorage();
|
||||
});
|
||||
}
|
||||
|
||||
function insertAfter(newNode, referenceNode) {
|
||||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||
}
|
||||
|
||||
function updateAuthenticatedUI(data) {
|
||||
toggleVisibility("loginForm", false);
|
||||
toggleVisibility("mainOperations", true);
|
||||
toggleVisibility("uploadFileForm", true);
|
||||
toggleVisibility("fileListContainer", true);
|
||||
attachEnterKeyListener("addUserModal", "saveUserBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||
|
||||
if (typeof data.totp_enabled !== "undefined") {
|
||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||
}
|
||||
|
||||
if (data.username) {
|
||||
localStorage.setItem("username", data.username);
|
||||
}
|
||||
if (typeof data.folderOnly !== "undefined") {
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
}
|
||||
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
|
||||
if (data.isAdmin) {
|
||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (!restoreBtn) {
|
||||
restoreBtn = document.createElement("button");
|
||||
restoreBtn.id = "restoreFilesBtn";
|
||||
restoreBtn.classList.add("btn", "btn-warning");
|
||||
restoreBtn.innerHTML = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>';
|
||||
if (firstButton) {
|
||||
insertAfter(restoreBtn, firstButton);
|
||||
} else {
|
||||
headerButtons.appendChild(restoreBtn);
|
||||
}
|
||||
}
|
||||
restoreBtn.style.display = "block";
|
||||
|
||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (!adminPanelBtn) {
|
||||
adminPanelBtn = document.createElement("button");
|
||||
adminPanelBtn.id = "adminPanelBtn";
|
||||
adminPanelBtn.classList.add("btn", "btn-info");
|
||||
adminPanelBtn.innerHTML = '<i class="material-icons" title="Admin Panel">admin_panel_settings</i>';
|
||||
insertAfter(adminPanelBtn, restoreBtn);
|
||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
||||
} else {
|
||||
adminPanelBtn.style.display = "block";
|
||||
}
|
||||
} else {
|
||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (restoreBtn) restoreBtn.style.display = "none";
|
||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
||||
if (!userPanelBtn) {
|
||||
userPanelBtn = document.createElement("button");
|
||||
userPanelBtn.id = "userPanelBtn";
|
||||
userPanelBtn.classList.add("btn", "btn-user");
|
||||
userPanelBtn.innerHTML = '<i class="material-icons" title="User Panel">account_circle</i>';
|
||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (adminPanelBtn) {
|
||||
insertAfter(userPanelBtn, adminPanelBtn);
|
||||
} else {
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
if (firstButton) {
|
||||
insertAfter(userPanelBtn, firstButton);
|
||||
} else {
|
||||
headerButtons.appendChild(userPanelBtn);
|
||||
}
|
||||
}
|
||||
userPanelBtn.addEventListener("click", openUserPanel);
|
||||
} else {
|
||||
userPanelBtn.style.display = "block";
|
||||
}
|
||||
|
||||
updateItemsPerPageSelect();
|
||||
updateLoginOptionsUIFromStorage();
|
||||
}
|
||||
|
||||
function checkAuthentication(showLoginToast = true) {
|
||||
return sendRequest("checkAuth.php")
|
||||
.then(data => {
|
||||
if (data.setup) {
|
||||
window.setupMode = true;
|
||||
if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user.");
|
||||
toggleVisibility("loginForm", false);
|
||||
toggleVisibility("mainOperations", false);
|
||||
document.querySelector(".header-buttons").style.visibility = "hidden";
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
return false;
|
||||
}
|
||||
window.setupMode = false;
|
||||
if (data.authenticated) {
|
||||
if (typeof data.totp_enabled !== "undefined") {
|
||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||
}
|
||||
updateAuthenticatedUI(data);
|
||||
return data;
|
||||
} else {
|
||||
if (showLoginToast) showToast("Please log in to continue.");
|
||||
toggleVisibility("loginForm", true);
|
||||
toggleVisibility("mainOperations", false);
|
||||
toggleVisibility("uploadFileForm", false);
|
||||
toggleVisibility("fileListContainer", false);
|
||||
document.querySelector(".header-buttons").style.visibility = "hidden";
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/* ----------------- Authentication Submission ----------------- */
|
||||
function submitLogin(data) {
|
||||
setLastLoginData(data);
|
||||
sendRequest("auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
|
||||
window.location.reload();
|
||||
} else if (response.totp_required) {
|
||||
openTOTPLoginModal();
|
||||
} else if (response.error && response.error.includes("Too many failed login attempts")) {
|
||||
showToast(response.error);
|
||||
const loginButton = document.getElementById("authForm").querySelector("button[type='submit']");
|
||||
if (loginButton) {
|
||||
loginButton.disabled = true;
|
||||
setTimeout(() => {
|
||||
loginButton.disabled = false;
|
||||
showToast("You can now try logging in again.");
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
} else {
|
||||
showToast("Login failed: " + (response.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Login failed: Unknown error");
|
||||
});
|
||||
}
|
||||
window.submitLogin = submitLogin;
|
||||
|
||||
/* ----------------- Other Helpers and Initialization ----------------- */
|
||||
window.changeItemsPerPage = function (value) {
|
||||
localStorage.setItem("itemsPerPage", value);
|
||||
if (typeof renderFileTable === "function") renderFileTable(window.currentFolder || "root");
|
||||
};
|
||||
|
||||
function resetUserForm() {
|
||||
document.getElementById("newUsername").value = "";
|
||||
document.getElementById("addUserPassword").value = "";
|
||||
}
|
||||
|
||||
function closeAddUserModal() {
|
||||
toggleVisibility("addUserModal", false);
|
||||
resetUserForm();
|
||||
}
|
||||
|
||||
function closeRemoveUserModal() {
|
||||
toggleVisibility("removeUserModal", false);
|
||||
document.getElementById("removeUsernameSelect").innerHTML = "";
|
||||
}
|
||||
|
||||
function loadUserList() {
|
||||
fetch("getUsers.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const users = Array.isArray(data) ? data : (data.users || []);
|
||||
const selectElem = document.getElementById("removeUsernameSelect");
|
||||
selectElem.innerHTML = "";
|
||||
users.forEach(user => {
|
||||
const option = document.createElement("option");
|
||||
option.value = user.username;
|
||||
option.textContent = user.username;
|
||||
selectElem.appendChild(option);
|
||||
});
|
||||
if (selectElem.options.length === 0) {
|
||||
showToast("No other users found to remove.");
|
||||
closeRemoveUserModal();
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}
|
||||
window.loadUserList = loadUserList;
|
||||
|
||||
function initAuth() {
|
||||
checkAuthentication(false);
|
||||
loadAdminConfigFunc();
|
||||
const authForm = document.getElementById("authForm");
|
||||
if (authForm) {
|
||||
authForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const rememberMe = document.getElementById("rememberMeCheckbox")
|
||||
? document.getElementById("rememberMeCheckbox").checked
|
||||
: false;
|
||||
const formData = {
|
||||
username: document.getElementById("loginUsername").value.trim(),
|
||||
password: document.getElementById("loginPassword").value.trim(),
|
||||
remember_me: rememberMe
|
||||
};
|
||||
submitLogin(formData);
|
||||
});
|
||||
}
|
||||
document.getElementById("logoutBtn").addEventListener("click", function () {
|
||||
fetch("logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": window.csrfToken }
|
||||
}).then(() => window.location.reload(true)).catch(() => { });
|
||||
});
|
||||
document.getElementById("addUserBtn").addEventListener("click", function () {
|
||||
resetUserForm();
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
});
|
||||
document.getElementById("saveUserBtn").addEventListener("click", function () {
|
||||
const newUsername = document.getElementById("newUsername").value.trim();
|
||||
const newPassword = document.getElementById("addUserPassword").value.trim();
|
||||
const isAdmin = document.getElementById("isAdmin").checked;
|
||||
if (!newUsername || !newPassword) {
|
||||
showToast("Username and password are required!");
|
||||
return;
|
||||
}
|
||||
let url = "addUser.php";
|
||||
if (window.setupMode) url += "?setup=1";
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("User added successfully!");
|
||||
closeAddUserModal();
|
||||
checkAuthentication(false);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not add user"));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
});
|
||||
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);
|
||||
|
||||
document.getElementById("removeUserBtn").addEventListener("click", function () {
|
||||
loadUserList();
|
||||
toggleVisibility("removeUserModal", true);
|
||||
});
|
||||
document.getElementById("deleteUserBtn").addEventListener("click", async function () {
|
||||
const selectElem = document.getElementById("removeUsernameSelect");
|
||||
const usernameToRemove = selectElem.value;
|
||||
if (!usernameToRemove) {
|
||||
showToast("Please select a user to remove.");
|
||||
return;
|
||||
}
|
||||
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
|
||||
if (!confirmed) return;
|
||||
fetch("removeUser.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ username: usernameToRemove })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("User removed successfully!");
|
||||
closeRemoveUserModal();
|
||||
loadUserList();
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not remove user"));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
});
|
||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
document.getElementById("oldPassword").focus();
|
||||
});
|
||||
document.getElementById("closeChangePasswordModal").addEventListener("click", function () {
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
});
|
||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||
const newPassword = document.getElementById("newPassword").value.trim();
|
||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
showToast("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const data = { oldPassword, newPassword, confirmPassword };
|
||||
fetch("changePassword.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.success);
|
||||
document.getElementById("oldPassword").value = "";
|
||||
document.getElementById("newPassword").value = "";
|
||||
document.getElementById("confirmPassword").value = "";
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
} else {
|
||||
showToast("Error: " + (result.error || "Could not change password."));
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error changing password."); });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
updateItemsPerPageSelect();
|
||||
updateLoginOptionsUI({
|
||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
|
||||
});
|
||||
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
|
||||
if (oidcLoginBtn) {
|
||||
oidcLoginBtn.addEventListener("click", () => {
|
||||
// Redirect to the OIDC auth endpoint. The endpoint can be adjusted if needed.
|
||||
window.location.href = "auth.php?oidc=initiate";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export { initAuth, checkAuthentication };
|
||||
234
auth.php
234
auth.php
@@ -1,234 +0,0 @@
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
/**
|
||||
* Helper: Get the user's role from users.txt.
|
||||
*/
|
||||
function getUserRole($username) {
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
if (count($parts) >= 3 && $parts[0] === $username) {
|
||||
return trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* --- OIDC Authentication Flow --- */
|
||||
if (isset($_GET['oidc'])) {
|
||||
// Read and decrypt OIDC configuration from JSON file.
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
if (file_exists($adminConfigFile)) {
|
||||
$encryptedContent = file_get_contents($adminConfigFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($decryptedContent === false) {
|
||||
// Log internal error and return a generic message.
|
||||
error_log("Failed to decrypt admin configuration.");
|
||||
echo json_encode(['error' => 'Internal error.']);
|
||||
exit;
|
||||
}
|
||||
$adminConfig = json_decode($decryptedContent, true);
|
||||
if (isset($adminConfig['oidc'])) {
|
||||
$oidcConfig = $adminConfig['oidc'];
|
||||
$oidc_provider_url = !empty($oidcConfig['providerUrl']) ? $oidcConfig['providerUrl'] : 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = !empty($oidcConfig['clientId']) ? $oidcConfig['clientId'] : 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = !empty($oidcConfig['clientSecret']) ? $oidcConfig['clientSecret'] : 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = !empty($oidcConfig['redirectUri']) ? $oidcConfig['redirectUri'] : 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
} else {
|
||||
$oidc_provider_url = 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
}
|
||||
} else {
|
||||
$oidc_provider_url = 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
|
||||
}
|
||||
|
||||
$oidc = new Jumbojett\OpenIDConnectClient(
|
||||
$oidc_provider_url,
|
||||
$oidc_client_id,
|
||||
$oidc_client_secret
|
||||
);
|
||||
$oidc->setRedirectURL($oidc_redirect_uri);
|
||||
|
||||
if ($_GET['oidc'] === 'callback') {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
$username = $oidc->requestUserInfo('preferred_username');
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
// Determine the user role from users.txt.
|
||||
$userRole = getUserRole($username);
|
||||
$_SESSION["isAdmin"] = ($userRole === "1");
|
||||
// *** Use loadUserPermissions() here instead of loadFolderPermission() ***
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
header("Location: index.html");
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
error_log("OIDC authentication error: " . $e->getMessage());
|
||||
echo json_encode(["error" => "Authentication failed."]);
|
||||
exit();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
error_log("OIDC initiation error: " . $e->getMessage());
|
||||
echo json_encode(["error" => "Authentication initiation failed."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Fallback: Form-based Authentication --- */
|
||||
// (Form-based branch code remains unchanged. It calls loadUserPermissions() in its basic auth branch.)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$maxAttempts = 5;
|
||||
$lockoutTime = 30 * 60;
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||||
$failedLogFile = USERS_DIR . 'failed_login.log';
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
|
||||
function loadFailedAttempts($file) {
|
||||
if (file_exists($file)) {
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveFailedAttempts($file, $data) {
|
||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$currentTime = time();
|
||||
$failedAttempts = loadFailedAttempts($attemptsFile);
|
||||
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$attemptData = $failedAttempts[$ip];
|
||||
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
|
||||
echo json_encode(["error" => "Too many failed login attempts. Please try again later."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
function authenticate($username, $password) {
|
||||
global $usersFile, $encryptionKey;
|
||||
if (!file_exists($usersFile)) {
|
||||
return false;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) < 3) continue;
|
||||
if ($username === $parts[0] && password_verify($password, $parts[1])) {
|
||||
$result = ['role' => $parts[2]];
|
||||
if (isset($parts[3]) && !empty($parts[3])) {
|
||||
$result['totp_secret'] = decryptData($parts[3], $encryptionKey);
|
||||
} else {
|
||||
$result['totp_secret'] = null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$username = trim($data["username"] ?? "");
|
||||
$password = trim($data["password"] ?? "");
|
||||
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
|
||||
|
||||
if (!$username || !$password) {
|
||||
echo json_encode(["error" => "Username and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$user = authenticate($username, $password);
|
||||
if ($user !== false) {
|
||||
if (!empty($user['totp_secret'])) {
|
||||
if (empty($data['totp_code'])) {
|
||||
echo json_encode([
|
||||
"totp_required" => true,
|
||||
"message" => "TOTP code required"
|
||||
]);
|
||||
exit();
|
||||
} else {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
||||
$providedCode = trim($data['totp_code']);
|
||||
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
|
||||
echo json_encode(["error" => "Invalid TOTP code"]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
unset($failedAttempts[$ip]);
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($user['role'] === "1");
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
|
||||
if ($rememberMe) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = time() + (30 * 24 * 60 * 60);
|
||||
$persistentTokens = [];
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (!is_array($persistentTokens)) {
|
||||
$persistentTokens = [];
|
||||
}
|
||||
}
|
||||
$persistentTokens[$token] = [
|
||||
"username" => $username,
|
||||
"expiry" => $expiry,
|
||||
"isAdmin" => ($_SESSION["isAdmin"] === true)
|
||||
];
|
||||
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
|
||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"success" => "Login successful",
|
||||
"isAdmin" => $_SESSION["isAdmin"],
|
||||
"folderOnly"=> $_SESSION["folderOnly"],
|
||||
"username" => $_SESSION["username"]
|
||||
]);
|
||||
} else {
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$failedAttempts[$ip]['count']++;
|
||||
$failedAttempts[$ip]['last_attempt'] = $currentTime;
|
||||
} else {
|
||||
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
|
||||
}
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
|
||||
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
|
||||
echo json_encode(["error" => "Invalid credentials"]);
|
||||
}
|
||||
?>
|
||||
655
authModals.js
655
authModals.js
@@ -1,655 +0,0 @@
|
||||
import { showToast, toggleVisibility } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
|
||||
const version = "v1.0.7";
|
||||
const adminTitle = `Admin Panel <small style="font-size: 12px; color: gray;">${version}</small>`;
|
||||
let lastLoginData = null;
|
||||
|
||||
export function setLastLoginData(data) {
|
||||
lastLoginData = data;
|
||||
}
|
||||
|
||||
export function openTOTPLoginModal() {
|
||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const modalBg = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
const textColor = isDarkMode ? "#e0e0e0" : "#000";
|
||||
|
||||
if (!totpLoginModal) {
|
||||
totpLoginModal = document.createElement("div");
|
||||
totpLoginModal.id = "totpLoginModal";
|
||||
totpLoginModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3200;
|
||||
`;
|
||||
totpLoginModal.innerHTML = `
|
||||
<div style="background: ${modalBg}; padding: 20px; border-radius: 8px; text-align: center; position: relative; color: ${textColor};">
|
||||
<span id="closeTOTPLoginModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>Enter TOTP Code</h3>
|
||||
<input type="text" id="totpLoginInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpLoginModal);
|
||||
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
||||
totpLoginModal.style.display = "none";
|
||||
});
|
||||
const totpInput = document.getElementById("totpLoginInput");
|
||||
totpInput.focus();
|
||||
totpInput.addEventListener("input", function () {
|
||||
if (this.value.trim().length === 6 && lastLoginData) {
|
||||
lastLoginData.totp_code = this.value.trim();
|
||||
totpLoginModal.style.display = "none";
|
||||
if (typeof window.submitLogin === "function") {
|
||||
window.submitLogin(lastLoginData);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
totpLoginModal.style.display = "flex";
|
||||
const modalContent = totpLoginModal.firstElementChild;
|
||||
modalContent.style.background = modalBg;
|
||||
modalContent.style.color = textColor;
|
||||
}
|
||||
}
|
||||
|
||||
export function openUserPanel() {
|
||||
const username = localStorage.getItem("username") || "User";
|
||||
let userPanelModal = document.getElementById("userPanelModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
transform: none;
|
||||
transition: none;
|
||||
`;
|
||||
if (!userPanelModal) {
|
||||
userPanelModal = document.createElement("div");
|
||||
userPanelModal.id = "userPanelModal";
|
||||
userPanelModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3000;
|
||||
`;
|
||||
userPanelModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>User Panel (${username})</h3>
|
||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">Change Password</button>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>TOTP Settings</legend>
|
||||
<div class="form-group">
|
||||
<label for="userTOTPEnabled">Enable TOTP:</label>
|
||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(userPanelModal);
|
||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
||||
userPanelModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
});
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
||||
totpCheckbox.addEventListener("change", function () {
|
||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
||||
const enabled = this.checked;
|
||||
fetch("updateUserPanel.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_enabled: enabled })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast("Error updating TOTP setting: " + result.error);
|
||||
} else if (enabled) {
|
||||
openTOTPModal();
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error updating TOTP setting."); });
|
||||
});
|
||||
} else {
|
||||
userPanelModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
userPanelModal.style.display = "flex";
|
||||
}
|
||||
|
||||
export function openTOTPModal() {
|
||||
let totpModal = document.getElementById("totpModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
if (!totpModal) {
|
||||
totpModal = document.createElement("div");
|
||||
totpModal.id = "totpModal";
|
||||
totpModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3100;
|
||||
`;
|
||||
totpModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>TOTP Setup</h3>
|
||||
<p>Scan this QR code with your authenticator app:</p>
|
||||
<img src="totp_setup.php?csrf=${encodeURIComponent(window.csrfToken)}" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
||||
<br/>
|
||||
<p>Enter the 6-digit code from your app to confirm setup:</p>
|
||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||
<br/><br/>
|
||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">Confirm</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(totpModal);
|
||||
// Bind the X button to call closeTOTPModal with disable=true
|
||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
||||
closeTOTPModal(true);
|
||||
});
|
||||
|
||||
// Add event listener for TOTP confirmation
|
||||
document.getElementById("confirmTOTPBtn").addEventListener("click", function () {
|
||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||
if (code.length !== 6) {
|
||||
showToast("Please enter a valid 6-digit code.");
|
||||
return;
|
||||
}
|
||||
// Call the endpoint to verify the TOTP code
|
||||
fetch("totp_verify.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ totp_code: code })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast("TOTP successfully enabled.");
|
||||
// On success, close the modal without disabling
|
||||
closeTOTPModal(false);
|
||||
} else {
|
||||
showToast("TOTP verification failed: " + (result.error || "Invalid code."));
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error verifying TOTP code."); });
|
||||
});
|
||||
} else {
|
||||
totpModal.style.display = "flex";
|
||||
totpModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = totpModal.querySelector(".modal-content");
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
}
|
||||
}
|
||||
|
||||
// Updated closeTOTPModal function with a disable parameter
|
||||
export function closeTOTPModal(disable = true) {
|
||||
const totpModal = document.getElementById("totpModal");
|
||||
if (totpModal) totpModal.style.display = "none";
|
||||
|
||||
if (disable) {
|
||||
// Uncheck the Enable TOTP checkbox
|
||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||
if (totpCheckbox) {
|
||||
totpCheckbox.checked = false;
|
||||
localStorage.setItem("userTOTPEnabled", "false");
|
||||
}
|
||||
// Call endpoint to remove the TOTP secret from the user's record
|
||||
fetch("totp_disable.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (!result.success) {
|
||||
showToast("Error disabling TOTP setting: " + result.error);
|
||||
}
|
||||
})
|
||||
.catch(() => { showToast("Error disabling TOTP setting."); });
|
||||
}
|
||||
}
|
||||
|
||||
export function openAdminPanel() {
|
||||
fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
||||
`;
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
|
||||
if (!adminModal) {
|
||||
adminModal = document.createElement("div");
|
||||
adminModal.id = "adminPanelModal";
|
||||
adminModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3000;
|
||||
`;
|
||||
// Added a version number next to "Admin Panel"
|
||||
adminModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>
|
||||
<h3>${adminTitle}</h3>
|
||||
</h3>
|
||||
<form id="adminPanelForm">
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>User Management</legend>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success">Add User</button>
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger">Remove User</button>
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">User Permissions</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>OIDC Configuration</legend>
|
||||
<div class="form-group">
|
||||
<label for="oidcProviderUrl">OIDC Provider URL:</label>
|
||||
<input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientId">OIDC Client ID:</label>
|
||||
<input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientSecret">OIDC Client Secret:</label>
|
||||
<input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcRedirectUri">OIDC Redirect URI:</label>
|
||||
<input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>Global TOTP Settings</legend>
|
||||
<div class="form-group">
|
||||
<label for="globalOtpauthUrl">Global OTPAuth URL:</label>
|
||||
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-bottom: 15px;">
|
||||
<legend>Login Options</legend>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableFormLogin" />
|
||||
<label for="disableFormLogin">Disable Login Form</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableBasicAuth" />
|
||||
<label for="disableBasicAuth">Disable Basic HTTP Auth</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="disableOIDCLogin" />
|
||||
<label for="disableOIDCLogin">Disable OIDC Login</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="saveAdminSettings" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(adminModal);
|
||||
|
||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||
adminModal.addEventListener("click", (e) => {
|
||||
if (e.target === adminModal) closeAdminPanel();
|
||||
});
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
document.getElementById("adminOpenAddUser").addEventListener("click", () => {
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
});
|
||||
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
|
||||
if (typeof window.loadUserList === "function") {
|
||||
window.loadUserList();
|
||||
}
|
||||
toggleVisibility("removeUserModal", true);
|
||||
});
|
||||
// New event binding for the User Permissions button:
|
||||
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
|
||||
openUserPermissionsModal();
|
||||
});
|
||||
document.getElementById("saveAdminSettings").addEventListener("click", () => {
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (totalDisabled === 3) {
|
||||
showToast("At least one login method must remain enabled.");
|
||||
disableOIDCLoginCheckbox.checked = false;
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({
|
||||
disableFormLogin: disableFormLoginCheckbox.checked,
|
||||
disableBasicAuth: disableBasicAuthCheckbox.checked,
|
||||
disableOIDCLogin: disableOIDCLoginCheckbox.checked
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const newOIDCConfig = {
|
||||
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||
clientId: document.getElementById("oidcClientId").value.trim(),
|
||||
clientSecret: document.getElementById("oidcClientSecret").value.trim(),
|
||||
redirectUri: document.getElementById("oidcRedirectUri").value.trim()
|
||||
};
|
||||
const disableFormLogin = disableFormLoginCheckbox.checked;
|
||||
const disableBasicAuth = disableBasicAuthCheckbox.checked;
|
||||
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
|
||||
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
sendRequest("updateConfig.php", "POST", {
|
||||
oidc: newOIDCConfig,
|
||||
disableFormLogin,
|
||||
disableBasicAuth,
|
||||
disableOIDCLogin,
|
||||
globalOtpauthUrl
|
||||
}, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast("Settings updated successfully.");
|
||||
localStorage.setItem("disableFormLogin", disableFormLogin);
|
||||
localStorage.setItem("disableBasicAuth", disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
|
||||
if (typeof window.updateLoginOptionsUI === "function") {
|
||||
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
||||
}
|
||||
closeAdminPanel();
|
||||
} else {
|
||||
showToast("Error updating settings: " + (response.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
});
|
||||
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
|
||||
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
|
||||
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
|
||||
function enforceLoginOptionConstraint(changedCheckbox) {
|
||||
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
|
||||
if (changedCheckbox.checked && totalDisabled === 3) {
|
||||
showToast("At least one login method must remain enabled.");
|
||||
changedCheckbox.checked = false;
|
||||
}
|
||||
}
|
||||
disableFormLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
|
||||
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
} else {
|
||||
adminModal.style.backgroundColor = overlayBackground;
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||
}
|
||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
|
||||
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
|
||||
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
|
||||
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
|
||||
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/FileRise?issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
adminModal.style.display = "flex";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) {
|
||||
adminModal.style.backgroundColor = "rgba(0,0,0,0.5)";
|
||||
const modalContent = adminModal.querySelector(".modal-content");
|
||||
if (modalContent) {
|
||||
modalContent.style.background = "#fff";
|
||||
modalContent.style.color = "#000";
|
||||
modalContent.style.border = "1px solid #ccc";
|
||||
}
|
||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
|
||||
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
|
||||
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
|
||||
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
|
||||
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/FileRise?issuer=FileRise';
|
||||
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
|
||||
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
||||
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
adminModal.style.display = "flex";
|
||||
} else {
|
||||
openAdminPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function closeAdminPanel() {
|
||||
const adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) adminModal.style.display = "none";
|
||||
}
|
||||
|
||||
// --- New: User Permissions Modal ---
|
||||
|
||||
export function openUserPermissionsModal() {
|
||||
let userPermissionsModal = document.getElementById("userPermissionsModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
if (!userPermissionsModal) {
|
||||
userPermissionsModal = document.createElement("div");
|
||||
userPermissionsModal.id = "userPermissionsModal";
|
||||
userPermissionsModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3500;
|
||||
`;
|
||||
userPermissionsModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPermissionsModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">×</span>
|
||||
<h3>User Permissions</h3>
|
||||
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
|
||||
<!-- User rows will be loaded here -->
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">Save Permissions</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(userPermissionsModal);
|
||||
document.getElementById("closeUserPermissionsModal").addEventListener("click", () => {
|
||||
userPermissionsModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
|
||||
userPermissionsModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
|
||||
// Collect permissions data from each user row.
|
||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||
const permissionsData = [];
|
||||
rows.forEach(row => {
|
||||
const username = row.getAttribute("data-username");
|
||||
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
|
||||
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
|
||||
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
|
||||
permissionsData.push({
|
||||
username,
|
||||
folderOnly: folderOnlyCheckbox.checked,
|
||||
readOnly: readOnlyCheckbox.checked,
|
||||
disableUpload: disableUploadCheckbox.checked
|
||||
});
|
||||
});
|
||||
// Send the permissionsData to the server.
|
||||
sendRequest("updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast("User permissions updated successfully.");
|
||||
userPermissionsModal.style.display = "none";
|
||||
} else {
|
||||
showToast("Error updating permissions: " + (response.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Error updating permissions.");
|
||||
});
|
||||
});
|
||||
} else {
|
||||
userPermissionsModal.style.display = "flex";
|
||||
}
|
||||
// Load the list of users into the modal.
|
||||
loadUserPermissionsList();
|
||||
}
|
||||
|
||||
function loadUserPermissionsList() {
|
||||
const listContainer = document.getElementById("userPermissionsList");
|
||||
if (!listContainer) return;
|
||||
listContainer.innerHTML = "";
|
||||
|
||||
// First, fetch the current permissions from the server.
|
||||
fetch("getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
// Then, fetch the list of users.
|
||||
return fetch("getUsers.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(usersData => {
|
||||
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
|
||||
if (users.length === 0) {
|
||||
listContainer.innerHTML = "<p>No users found.</p>";
|
||||
return;
|
||||
}
|
||||
users.forEach(user => {
|
||||
// Skip admin users.
|
||||
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
|
||||
|
||||
// Use stored permissions if available; otherwise fall back to localStorage defaults.
|
||||
const defaultPerm = {
|
||||
folderOnly: localStorage.getItem("folderOnly") === "true",
|
||||
readOnly: localStorage.getItem("readOnly") === "true",
|
||||
disableUpload: localStorage.getItem("disableUpload") === "true"
|
||||
};
|
||||
const userPerm = (permissionsData && typeof permissionsData === "object" && permissionsData[user.username]) || defaultPerm;
|
||||
|
||||
// Create a row for the user.
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
row.style.padding = "10px 0";
|
||||
row.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
|
||||
User Folder Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
|
||||
Read Only
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
|
||||
Disable Upload
|
||||
</label>
|
||||
</div>
|
||||
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
|
||||
`;
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
listContainer.innerHTML = "<p>Error loading users.</p>";
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
// changePassword.php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
echo json_encode(["error" => "No username in session"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF token check.
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get POST data.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||
$newPassword = trim($data["newPassword"] ?? "");
|
||||
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
||||
|
||||
// Validate input.
|
||||
if (!$oldPassword || !$newPassword || !$confirmPassword) {
|
||||
echo json_encode(["error" => "All fields are required."]);
|
||||
exit;
|
||||
}
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
echo json_encode(["error" => "New passwords do not match."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Path to users file.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
echo json_encode(["error" => "Users file not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read current users.
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$userFound = false;
|
||||
$newLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
// Expect at least 3 parts: username, hashed password, and role.
|
||||
if (count($parts) < 3) {
|
||||
// Skip invalid lines.
|
||||
$newLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
$storedUser = $parts[0];
|
||||
$storedHash = $parts[1];
|
||||
$storedRole = $parts[2];
|
||||
// Preserve TOTP secret if it exists.
|
||||
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
||||
|
||||
if ($storedUser === $username) {
|
||||
$userFound = true;
|
||||
// Verify the old password.
|
||||
if (!password_verify($oldPassword, $storedHash)) {
|
||||
echo json_encode(["error" => "Old password is incorrect."]);
|
||||
exit;
|
||||
}
|
||||
// Hash the new password.
|
||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
// Rebuild the line with the new hash and preserve TOTP secret if present.
|
||||
if ($totpSecret !== "") {
|
||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
||||
} else {
|
||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
|
||||
}
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$userFound) {
|
||||
echo json_encode(["error" => "User not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Save updated users file.
|
||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
||||
echo json_encode(["success" => "Password updated successfully."]);
|
||||
} else {
|
||||
echo json_encode(["error" => "Could not update password."]);
|
||||
}
|
||||
?>
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check if users.txt is empty or doesn't exist.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
|
||||
// In production, you might log that the system is in setup mode.
|
||||
error_log("checkAuth: users file not found or empty; entering setup mode.");
|
||||
echo json_encode(["setup" => true]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check session authentication.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["authenticated" => false]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get a user's role from users.txt.
|
||||
* Returns the role as a string (e.g. "1") or null if not found.
|
||||
*/
|
||||
function getUserRole($username) {
|
||||
global $usersFile;
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
if (count($parts) >= 3 && $parts[0] === $username) {
|
||||
return trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if TOTP is enabled by checking users.txt.
|
||||
$totp_enabled = false;
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if ($username) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
// Assuming first field is username and fourth (if exists) is the TOTP secret.
|
||||
if ($parts[0] === $username) {
|
||||
if (isset($parts[3]) && trim($parts[3]) !== "") {
|
||||
$totp_enabled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use getUserRole() to determine admin status.
|
||||
// We cast the role to an integer so that "1" (string) is treated as true.
|
||||
$userRole = getUserRole($username);
|
||||
$isAdmin = ((int)$userRole === 1);
|
||||
|
||||
// Build and return the JSON response.
|
||||
$response = [
|
||||
"authenticated" => true,
|
||||
"isAdmin" => $isAdmin,
|
||||
"totp_enabled" => $totp_enabled,
|
||||
"username" => $username,
|
||||
"folderOnly" => isset($_SESSION["folderOnly"]) ? $_SESSION["folderOnly"] : false
|
||||
];
|
||||
|
||||
echo json_encode($response);
|
||||
?>
|
||||
12
codeql-config.yml
Normal file
12
codeql-config.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: FileRise CodeQL config
|
||||
paths:
|
||||
- public/js
|
||||
- api
|
||||
paths-ignore:
|
||||
- public/vendor/**
|
||||
- public/css/vendor/**
|
||||
- public/fonts/**
|
||||
- public/**/*.min.js
|
||||
- public/**/*.min.css
|
||||
- public/**/*.map
|
||||
12
composer.json
Normal file
12
composer.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "error311/filerise",
|
||||
"description": "FileRise – A lightweight self-hosted file manager",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"jumbojett/openid-connect-php": "^1.0.0",
|
||||
"phpseclib/phpseclib": "~3.0.7",
|
||||
"robthree/twofactorauth": "^3.0",
|
||||
"endroid/qr-code": "^5.0",
|
||||
"sabre/dav": "^4.4"
|
||||
}
|
||||
}
|
||||
1040
composer.lock
generated
Normal file
1040
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
145
config.php
145
config.php
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
// config.php
|
||||
|
||||
// Define constants.
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
define('USERS_DIR', '/var/www/users/');
|
||||
define('USERS_FILE', 'users.txt');
|
||||
define('META_DIR', '/var/www/metadata/');
|
||||
define('META_FILE', 'file_metadata.json');
|
||||
define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
||||
define('TIMEZONE', 'America/New_York');
|
||||
define('DATE_TIME_FORMAT', 'm/d/y h:iA');
|
||||
define('TOTAL_UPLOAD_SIZE', '5G');
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
/**
|
||||
* Encrypts data using AES-256-CBC.
|
||||
*
|
||||
* @param string $data The plaintext.
|
||||
* @param string $encryptionKey The encryption key.
|
||||
* @return string Base64-encoded string containing IV and ciphertext.
|
||||
*/
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
$cipher = 'AES-256-CBC';
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = openssl_random_pseudo_bytes($ivlen);
|
||||
$ciphertext = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data encrypted with AES-256-CBC.
|
||||
*
|
||||
* @param string $encryptedData Base64-encoded data containing IV and ciphertext.
|
||||
* @param string $encryptionKey The encryption key.
|
||||
* @return string|false The decrypted plaintext or false on failure.
|
||||
*/
|
||||
function decryptData($encryptedData, $encryptionKey)
|
||||
{
|
||||
$cipher = 'AES-256-CBC';
|
||||
$data = base64_decode($encryptedData);
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($data, 0, $ivlen);
|
||||
$ciphertext = substr($data, $ivlen);
|
||||
return openssl_decrypt($ciphertext, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
}
|
||||
|
||||
// Load encryption key from environment (override in production).
|
||||
$encryptionKey = getenv('PERSISTENT_TOKENS_KEY') ?: 'default_please_change_this_key';
|
||||
if (!$encryptionKey) {
|
||||
die('Encryption key for persistent tokens is not set.');
|
||||
}
|
||||
|
||||
function loadUserPermissions($username)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
|
||||
// Try to decrypt the content.
|
||||
$decryptedContent = decryptData($content, $encryptionKey);
|
||||
if ($decryptedContent !== false) {
|
||||
$permissions = json_decode($decryptedContent, true);
|
||||
} else {
|
||||
$permissions = json_decode($content, true);
|
||||
}
|
||||
|
||||
if (is_array($permissions) && array_key_exists($username, $permissions)) {
|
||||
$result = $permissions[$username];
|
||||
return !empty($result) ? $result : false;
|
||||
}
|
||||
}
|
||||
// Removed error_log() to prevent flooding logs when file is not found.
|
||||
return false; // Return false if no permissions found.
|
||||
}
|
||||
|
||||
// Determine whether HTTPS is used.
|
||||
$envSecure = getenv('SECURE');
|
||||
if ($envSecure !== false) {
|
||||
$secure = filter_var($envSecure, FILTER_VALIDATE_BOOLEAN);
|
||||
} else {
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
}
|
||||
|
||||
$cookieParams = [
|
||||
'lifetime' => 7200,
|
||||
'path' => '/',
|
||||
'domain' => '', // Set your domain as needed.
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
];
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params($cookieParams);
|
||||
ini_set('session.gc_maxlifetime', 7200);
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// Auto-login via persistent token.
|
||||
if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token'])) {
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$persistentTokens = [];
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (!is_array($persistentTokens)) {
|
||||
$persistentTokens = [];
|
||||
}
|
||||
}
|
||||
if (isset($persistentTokens[$_COOKIE['remember_me_token']])) {
|
||||
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
|
||||
if ($tokenData['expiry'] >= time()) {
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $tokenData["username"];
|
||||
// IMPORTANT: Set the folderOnly flag here for auto-login.
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($tokenData["username"]);
|
||||
} else {
|
||||
unset($persistentTokens[$_COOKIE['remember_me_token']]);
|
||||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||
|
||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
$defaultShareUrl = isset($_SERVER['HTTP_HOST'])
|
||||
? "http://" . $_SERVER['HTTP_HOST'] . "/share.php"
|
||||
: "http://localhost/share.php";
|
||||
} else {
|
||||
$defaultShareUrl = rtrim(BASE_URL, '/') . "/share.php";
|
||||
}
|
||||
define('SHARE_URL', getenv('SHARE_URL') ? getenv('SHARE_URL') : $defaultShareUrl);
|
||||
312
config/config.php
Normal file
312
config/config.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// config.php
|
||||
|
||||
// Define constants
|
||||
define('PROJECT_ROOT', dirname(__DIR__));
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
define('USERS_DIR', '/var/www/users/');
|
||||
define('USERS_FILE', 'users.txt');
|
||||
define('META_DIR', '/var/www/metadata/');
|
||||
define('META_FILE', 'file_metadata.json');
|
||||
define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
||||
define('TIMEZONE', 'America/New_York');
|
||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||
define('TOTAL_UPLOAD_SIZE','5G');
|
||||
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
define('FR_DEMO_MODE', false);
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||
define('ACL_INHERIT_ON_CREATE', true);
|
||||
// ONLYOFFICE integration overrides (uncomment and set as needed)
|
||||
/*
|
||||
define('ONLYOFFICE_ENABLED', false);
|
||||
define('ONLYOFFICE_JWT_SECRET', 'test123456');
|
||||
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||
}
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
$cipher = 'AES-256-CBC';
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = openssl_random_pseudo_bytes($ivlen);
|
||||
$ct = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $ct);
|
||||
}
|
||||
|
||||
function decryptData($encryptedData, $encryptionKey)
|
||||
{
|
||||
$cipher = 'AES-256-CBC';
|
||||
$data = base64_decode($encryptedData);
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($data, 0, $ivlen);
|
||||
$ct = substr($data, $ivlen);
|
||||
return openssl_decrypt($ct, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
}
|
||||
|
||||
// Load encryption key
|
||||
$envKey = getenv('PERSISTENT_TOKENS_KEY');
|
||||
if ($envKey === false || $envKey === '') {
|
||||
$encryptionKey = 'default_please_change_this_key';
|
||||
error_log('WARNING: Using default encryption key. Please set PERSISTENT_TOKENS_KEY in your environment.');
|
||||
} else {
|
||||
$encryptionKey = $envKey;
|
||||
}
|
||||
|
||||
// Helper to load JSON permissions (with optional decryption)
|
||||
function loadUserPermissions($username)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
if (!file_exists($permissionsFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($permissionsFile);
|
||||
$decrypted = decryptData($content, $encryptionKey);
|
||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||
$permsAll = json_decode($json, true);
|
||||
|
||||
if (!is_array($permsAll)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try exact match first, then lowercase (since we store keys lowercase elsewhere)
|
||||
$uExact = (string)$username;
|
||||
$uLower = strtolower($uExact);
|
||||
|
||||
$row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null;
|
||||
|
||||
// Normalize: always return an array when found, else false (to preserve current callers’ behavior)
|
||||
return is_array($row) ? $row : false;
|
||||
}
|
||||
|
||||
// Determine HTTPS usage
|
||||
$envSecure = getenv('SECURE');
|
||||
$secure = ($envSecure !== false)
|
||||
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
|
||||
// PHP session lifetime (independent of "remember me")
|
||||
// Keep this reasonably short; "remember me" uses its own token.
|
||||
$defaultSession = 7200; // 2 hours
|
||||
$sessionLifetime = $defaultSession;
|
||||
|
||||
// "Remember me" window (how long the persistent token itself is valid)
|
||||
// This is used in persistent_tokens.json, *not* for PHP session lifetime.
|
||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
/**
|
||||
* Start session idempotently:
|
||||
* - If no session: set cookie params + gc_maxlifetime, then session_start().
|
||||
* - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry.
|
||||
*/
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => $sessionLifetime,
|
||||
'path' => '/',
|
||||
'domain' => '', // adjust if you need a specific domain
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||
session_start();
|
||||
} else {
|
||||
// Optionally refresh the session cookie expiry to keep the user alive
|
||||
$params = session_get_cookie_params();
|
||||
if ($sessionLifetime > 0) {
|
||||
setcookie(session_name(), session_id(), [
|
||||
'expires' => time() + $sessionLifetime,
|
||||
'path' => $params['path'] ?: '/',
|
||||
'domain' => $params['domain'] ?? '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => $params['samesite'] ?? 'Lax',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF token
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// Auto-login via persistent token
|
||||
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$tokens = [];
|
||||
if (file_exists($tokFile)) {
|
||||
$enc = file_get_contents($tokFile);
|
||||
$dec = decryptData($enc, $encryptionKey);
|
||||
$tokens = json_decode($dec, true) ?: [];
|
||||
}
|
||||
$token = $_COOKIE['remember_me_token'];
|
||||
if (!empty($tokens[$token])) {
|
||||
$data = $tokens[$token];
|
||||
if ($data['expiry'] >= time()) {
|
||||
// NEW: mitigate session fixation
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $data["username"];
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
||||
$_SESSION["isAdmin"] = !empty($data["isAdmin"]);
|
||||
} else {
|
||||
// expired — clean up
|
||||
unset($tokens[$token]);
|
||||
file_put_contents(
|
||||
$tokFile,
|
||||
encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey),
|
||||
LOCK_EX
|
||||
);
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
|
||||
// sane defaults:
|
||||
$cfgAuthBypass = false;
|
||||
$cfgAuthHeader = 'X_REMOTE_USER';
|
||||
|
||||
if (file_exists($adminConfigFile)) {
|
||||
$encrypted = file_get_contents($adminConfigFile);
|
||||
$decrypted = decryptData($encrypted, $encryptionKey);
|
||||
$adminCfg = json_decode($decrypted, true) ?: [];
|
||||
|
||||
$loginOpts = $adminCfg['loginOptions'] ?? [];
|
||||
|
||||
// proxy-only bypass flag
|
||||
$cfgAuthBypass = ! empty($loginOpts['authBypass']);
|
||||
|
||||
// header name (e.g. “X-Remote-User” → HTTP_X_REMOTE_USER)
|
||||
$hdr = trim($loginOpts['authHeaderName'] ?? '');
|
||||
if ($hdr === '') {
|
||||
$hdr = 'X-Remote-User';
|
||||
}
|
||||
// normalize to PHP’s $_SERVER key format:
|
||||
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
|
||||
}
|
||||
|
||||
define('AUTH_BYPASS', $cfgAuthBypass);
|
||||
define('AUTH_HEADER', $cfgAuthHeader);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PROXY-ONLY AUTO–LOGIN now uses those constants:
|
||||
if (AUTH_BYPASS) {
|
||||
$hdrKey = AUTH_HEADER; // e.g. "HTTP_X_REMOTE_USER"
|
||||
if (!empty($_SERVER[$hdrKey])) {
|
||||
// regenerate once per session
|
||||
if (empty($_SESSION['authenticated'])) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
$username = $_SERVER[$hdrKey];
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $username;
|
||||
|
||||
// ◾ lookup actual role instead of forcing admin
|
||||
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
|
||||
$role = AuthModel::getUserRole($username);
|
||||
$_SESSION['isAdmin'] = ($role === '1');
|
||||
|
||||
// carry over any folder/read/upload perms
|
||||
$perms = loadUserPermissions($username) ?: [];
|
||||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// Share URL fallback (keep BASE_URL behavior)
|
||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||
|
||||
// Detect scheme correctly (works behind proxies too)
|
||||
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
|
||||
);
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
|
||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||
} else {
|
||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||
}
|
||||
|
||||
// Final: env var wins, else fallback
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// FileRise Pro bootstrap wiring
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
|
||||
if (!defined('FR_PRO_LICENSE')) {
|
||||
$envLicense = getenv('FR_PRO_LICENSE');
|
||||
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
|
||||
}
|
||||
|
||||
// JSON license file used by AdminController::setLicense()
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
|
||||
}
|
||||
|
||||
// Optional plain-text license file (used as fallback in bootstrap)
|
||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||
if ($lf === false || $lf === '') {
|
||||
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
|
||||
}
|
||||
define('FR_PRO_LICENSE_FILE', $lf);
|
||||
}
|
||||
|
||||
// Where Pro code lives by default → inside users volume
|
||||
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||
if ($proDir === false || $proDir === '') {
|
||||
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
|
||||
}
|
||||
$proDir = rtrim($proDir, "/\\");
|
||||
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||
define('FR_PRO_BUNDLE_DIR', $proDir);
|
||||
}
|
||||
|
||||
// Try to load Pro bootstrap if enabled + present
|
||||
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
|
||||
if (@is_file($proBootstrap)) {
|
||||
require_once $proBootstrap;
|
||||
}
|
||||
|
||||
// If bootstrap didn’t define these, give safe defaults
|
||||
if (!defined('FR_PRO_ACTIVE')) {
|
||||
define('FR_PRO_ACTIVE', false);
|
||||
}
|
||||
if (!defined('FR_PRO_INFO')) {
|
||||
define('FR_PRO_INFO', [
|
||||
'valid' => false,
|
||||
'error' => null,
|
||||
'payload' => null,
|
||||
]);
|
||||
}
|
||||
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||
define('FR_PRO_BUNDLE_VERSION', null);
|
||||
}
|
||||
153
copyFiles.php
153
copyFiles.php
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if (
|
||||
!$data ||
|
||||
!isset($data['source']) ||
|
||||
!isset($data['destination']) ||
|
||||
!isset($data['files'])
|
||||
) {
|
||||
echo json_encode(["error" => "Invalid request"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceFolder = trim($data['source']);
|
||||
$destinationFolder = trim($data['destination']);
|
||||
$files = $data['files'];
|
||||
|
||||
// Validate folder names: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
$folderPattern = '/^[A-Za-z0-9_\- \/]+$/';
|
||||
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
|
||||
echo json_encode(["error" => "Invalid source folder name."]);
|
||||
exit;
|
||||
}
|
||||
if ($destinationFolder !== 'root' && !preg_match($folderPattern, $destinationFolder)) {
|
||||
echo json_encode(["error" => "Invalid destination folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Trim any leading/trailing slashes and spaces.
|
||||
$sourceFolder = trim($sourceFolder, "/\\ ");
|
||||
$destinationFolder = trim($destinationFolder, "/\\ ");
|
||||
|
||||
// Build the source and destination directories.
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$sourceDir = ($sourceFolder === 'root')
|
||||
? $baseDir . DIRECTORY_SEPARATOR
|
||||
: $baseDir . DIRECTORY_SEPARATOR . $sourceFolder . DIRECTORY_SEPARATOR;
|
||||
$destDir = ($destinationFolder === 'root')
|
||||
? $baseDir . DIRECTORY_SEPARATOR
|
||||
: $baseDir . DIRECTORY_SEPARATOR . $destinationFolder . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Helper: Generate the metadata file path for a given folder.
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
// Helper: Generate a unique file name if a file with the same name exists.
|
||||
function getUniqueFileName($destDir, $fileName) {
|
||||
$fullPath = $destDir . $fileName;
|
||||
clearstatcache(true, $fullPath);
|
||||
if (!file_exists($fullPath)) {
|
||||
return $fileName;
|
||||
}
|
||||
$basename = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
|
||||
$counter = 1;
|
||||
do {
|
||||
$newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : "");
|
||||
$newFullPath = $destDir . $newName;
|
||||
clearstatcache(true, $newFullPath);
|
||||
$counter++;
|
||||
} while (file_exists($destDir . $newName));
|
||||
return $newName;
|
||||
}
|
||||
|
||||
// Load source and destination metadata.
|
||||
$srcMetaFile = getMetadataFilePath($sourceFolder);
|
||||
$destMetaFile = getMetadataFilePath($destinationFolder);
|
||||
|
||||
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
|
||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
||||
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
// Save the original name for metadata lookup.
|
||||
$originalName = basename(trim($fileName));
|
||||
$basename = $originalName;
|
||||
if (!preg_match($safeFileNamePattern, $basename)) {
|
||||
$errors[] = "$basename has an invalid name.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$srcPath = $sourceDir . $originalName;
|
||||
$destPath = $destDir . $basename;
|
||||
|
||||
clearstatcache();
|
||||
if (!file_exists($srcPath)) {
|
||||
$errors[] = "$originalName does not exist in source.";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file_exists($destPath)) {
|
||||
$uniqueName = getUniqueFileName($destDir, $basename);
|
||||
$basename = $uniqueName; // update the file name for metadata and destination path
|
||||
$destPath = $destDir . $uniqueName;
|
||||
}
|
||||
|
||||
if (!copy($srcPath, $destPath)) {
|
||||
$errors[] = "Failed to copy $basename";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update destination metadata: if there's metadata for the original file in source, add it under the new name.
|
||||
if (isset($srcMetadata[$originalName])) {
|
||||
$destMetadata[$basename] = $srcMetadata[$originalName];
|
||||
}
|
||||
}
|
||||
|
||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
||||
$errors[] = "Failed to update destination metadata.";
|
||||
}
|
||||
|
||||
if (empty($errors)) {
|
||||
echo json_encode(["success" => "Files copied successfully"]);
|
||||
} else {
|
||||
echo json_encode(["error" => implode("; ", $errors)]);
|
||||
}
|
||||
?>
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure the request is a POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid request method.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token.']);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create folders."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the JSON input and decode it
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!isset($input['folderName'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Folder name not provided.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folderName = trim($input['folderName']);
|
||||
$parent = isset($input['parent']) ? trim($input['parent']) : "";
|
||||
|
||||
// Basic sanitation: allow only letters, numbers, underscores, dashes, and spaces in folderName
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $folderName)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optionally, sanitize the parent folder if needed.
|
||||
if ($parent && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $parent)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid parent folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build the full folder path.
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
if ($parent && strtolower($parent) !== "root") {
|
||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
|
||||
$relativePath = $parent . "/" . $folderName;
|
||||
} else {
|
||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
|
||||
$relativePath = $folderName;
|
||||
}
|
||||
|
||||
// Check if the folder already exists.
|
||||
if (file_exists($fullPath)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Folder already exists.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attempt to create the folder.
|
||||
if (mkdir($fullPath, 0755, true)) {
|
||||
|
||||
// --- Create an empty metadata file for the new folder ---
|
||||
// Helper: Generate the metadata file path for a given folder.
|
||||
// For "root", returns "root_metadata.json". Otherwise, replaces slashes, backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
$metadataFile = getMetadataFilePath($relativePath);
|
||||
// Create an empty associative array (i.e. empty metadata) and write to the metadata file.
|
||||
file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create folder.']);
|
||||
}
|
||||
?>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
// createShareLink.php
|
||||
require_once 'config.php';
|
||||
|
||||
// Get POST input.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$input) {
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = isset($input['folder']) ? trim($input['folder']) : "";
|
||||
$file = isset($input['file']) ? basename($input['file']) : "";
|
||||
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60;
|
||||
$password = isset($input['password']) ? $input['password'] : "";
|
||||
|
||||
// Validate folder using regex.
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate a secure token.
|
||||
$token = bin2hex(random_bytes(16)); // 32 hex characters.
|
||||
|
||||
// Calculate expiration (Unix timestamp).
|
||||
$expires = time() + ($expirationMinutes * 60);
|
||||
|
||||
// Hash password if provided.
|
||||
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// File to store share links.
|
||||
$shareFile = META_DIR . "share_links.json";
|
||||
$shareLinks = [];
|
||||
if (file_exists($shareFile)) {
|
||||
$data = file_get_contents($shareFile);
|
||||
$shareLinks = json_decode($data, true);
|
||||
if (!is_array($shareLinks)) {
|
||||
$shareLinks = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired share links.
|
||||
$currentTime = time();
|
||||
foreach ($shareLinks as $key => $link) {
|
||||
if ($link["expires"] < $currentTime) {
|
||||
unset($shareLinks[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add record.
|
||||
$shareLinks[$token] = [
|
||||
"folder" => $folder,
|
||||
"file" => $file,
|
||||
"expires" => $expires,
|
||||
"password" => $hashedPassword
|
||||
];
|
||||
|
||||
// Save the share links.
|
||||
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) {
|
||||
echo json_encode(["token" => $token, "expires" => $expires]);
|
||||
} else {
|
||||
echo json_encode(["error" => "Could not save share link."]);
|
||||
}
|
||||
?>
|
||||
53
custom-php.ini
Normal file
53
custom-php.ini
Normal file
@@ -0,0 +1,53 @@
|
||||
; custom-php.ini
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; OPcache Settings
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
opcache.enable=1
|
||||
opcache.enable_cli=0
|
||||
; Allocate 128MB of memory for opcode caching
|
||||
opcache.memory_consumption=128
|
||||
; Increase the maximum number of accelerated files (adjust if you have a large codebase)
|
||||
opcache.max_accelerated_files=4000
|
||||
; Refresh file timestamp every 60 seconds to avoid too many disk reads
|
||||
opcache.revalidate_freq=60
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Memory and Execution Time Limits
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Increase memory limit to 512M for large file processing or image processing operations
|
||||
memory_limit=512M
|
||||
; Set execution time limits to accommodate long-running uploads/processes
|
||||
max_execution_time=300
|
||||
max_input_time=300
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Realpath Cache Settings
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
realpath_cache_size=4096k
|
||||
realpath_cache_ttl=600
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; File Upload Settings
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Allow a maximum of 20 files per request
|
||||
max_file_uploads=20
|
||||
; Ensure the temporary directory is set (should exist and be writable)
|
||||
upload_tmp_dir=/tmp
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Session Configuration (if applicable)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
session.gc_maxlifetime=1440
|
||||
session.gc_probability=1
|
||||
session.gc_divisor=100
|
||||
session.save_path = "/var/www/sessions"
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Error Handling / Logging
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; Do not display errors publicly in production
|
||||
display_errors=Off
|
||||
; Log errors to a dedicated file
|
||||
log_errors=On
|
||||
error_log=/var/log/php8.3-error.log
|
||||
161
deleteFiles.php
161
deleteFiles.php
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Define $username first.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
|
||||
// Now load the user's permissions.
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
|
||||
// Check if the user is read-only.
|
||||
if ($username) {
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Setup Trash Folder & Metadata ---
|
||||
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!file_exists($trashDir)) {
|
||||
mkdir($trashDir, 0755, true);
|
||||
}
|
||||
$trashMetadataFile = $trashDir . "trash.json";
|
||||
$trashData = [];
|
||||
if (file_exists($trashMetadataFile)) {
|
||||
$json = file_get_contents($trashMetadataFile);
|
||||
$trashData = json_decode($json, true);
|
||||
if (!is_array($trashData)) {
|
||||
$trashData = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Generate the metadata file path for a given folder.
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
// Read request body
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// Validate request
|
||||
if (!isset($data['files']) || !is_array($data['files'])) {
|
||||
echo json_encode(["error" => "No file names provided"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine folder – default to 'root'
|
||||
$folder = isset($data['folder']) ? trim($data['folder']) : 'root';
|
||||
|
||||
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
$folder = trim($folder, "/\\ ");
|
||||
|
||||
// Build the upload directory.
|
||||
if ($folder !== 'root') {
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
// Load folder metadata (if exists) to retrieve uploader and upload date.
|
||||
$metadataFile = getMetadataFilePath($folder);
|
||||
$folderMetadata = [];
|
||||
if (file_exists($metadataFile)) {
|
||||
$folderMetadata = json_decode(file_get_contents($metadataFile), true);
|
||||
if (!is_array($folderMetadata)) {
|
||||
$folderMetadata = [];
|
||||
}
|
||||
}
|
||||
|
||||
$movedFiles = [];
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern: allow letters, numbers, underscores, dashes, dots, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
foreach ($data['files'] as $fileName) {
|
||||
$basename = basename(trim($fileName));
|
||||
|
||||
// Validate the file name.
|
||||
if (!preg_match($safeFileNamePattern, $basename)) {
|
||||
$errors[] = "$basename has an invalid name.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $uploadDir . $basename;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
// Append a timestamp to the file name in trash to avoid collisions.
|
||||
$timestamp = time();
|
||||
$trashFileName = $basename . "_" . $timestamp;
|
||||
if (rename($filePath, $trashDir . $trashFileName)) {
|
||||
$movedFiles[] = $basename;
|
||||
// Record trash metadata for possible restoration.
|
||||
$trashData[] = [
|
||||
'type' => 'file',
|
||||
'originalFolder' => $uploadDir, // You could also store a relative path here.
|
||||
'originalName' => $basename,
|
||||
'trashName' => $trashFileName,
|
||||
'trashedAt' => $timestamp,
|
||||
// Enrich trash record with uploader and upload date from folder metadata (if available)
|
||||
'uploaded' => isset($folderMetadata[$basename]['uploaded']) ? $folderMetadata[$basename]['uploaded'] : "Unknown",
|
||||
'uploader' => isset($folderMetadata[$basename]['uploader']) ? $folderMetadata[$basename]['uploader'] : "Unknown",
|
||||
// NEW: Record the username of the user who deleted the file.
|
||||
'deletedBy' => isset($_SESSION['username']) ? $_SESSION['username'] : "Unknown"
|
||||
];
|
||||
} else {
|
||||
$errors[] = "Failed to move $basename to Trash.";
|
||||
}
|
||||
} else {
|
||||
// Consider file already deleted.
|
||||
$movedFiles[] = $basename;
|
||||
}
|
||||
}
|
||||
|
||||
// Write back the updated trash metadata.
|
||||
file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT));
|
||||
|
||||
// Update folder-specific metadata file by removing deleted files.
|
||||
if (file_exists($metadataFile)) {
|
||||
$metadata = json_decode(file_get_contents($metadataFile), true);
|
||||
if (is_array($metadata)) {
|
||||
foreach ($movedFiles as $delFile) {
|
||||
if (isset($metadata[$delFile])) {
|
||||
unset($metadata[$delFile]);
|
||||
}
|
||||
}
|
||||
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($errors)) {
|
||||
echo json_encode(["success" => "Files moved to Trash: " . implode(", ", $movedFiles)]);
|
||||
} else {
|
||||
echo json_encode(["error" => implode("; ", $errors) . ". Files moved to Trash: " . implode(", ", $movedFiles)]);
|
||||
}
|
||||
?>
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure the request is a POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid request method.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token.']);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to delete folders."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the JSON input and decode it
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!isset($input['folder'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Folder name not provided.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folderName = trim($input['folder']);
|
||||
|
||||
// Prevent deletion of root.
|
||||
if ($folderName === 'root') {
|
||||
echo json_encode(['success' => false, 'error' => 'Cannot delete root folder.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Allow letters, numbers, underscores, dashes, spaces, and forward slashes.
|
||||
if (!preg_match('/^[A-Za-z0-9_\- \/]+$/', $folderName)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build the folder path (supports subfolder paths like "FolderTest/FolderTestSub")
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
// Check if the folder exists and is a directory
|
||||
if (!file_exists($folderPath) || !is_dir($folderPath)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Folder does not exist.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Prevent deletion if the folder is not empty
|
||||
if (count(scandir($folderPath)) > 2) {
|
||||
echo json_encode(['success' => false, 'error' => 'Folder is not empty.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate the metadata file path for a given folder.
|
||||
* For "root", returns "root_metadata.json". Otherwise, it replaces
|
||||
* slashes, backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
*
|
||||
* @param string $folder The folder's relative path.
|
||||
* @return string The full path to the folder's metadata file.
|
||||
*/
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
// Attempt to delete the folder.
|
||||
if (rmdir($folderPath)) {
|
||||
// Remove corresponding metadata file if it exists.
|
||||
$metadataFile = getMetadataFilePath($folderName);
|
||||
if (file_exists($metadataFile)) {
|
||||
unlink($metadataFile);
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to delete folder.']);
|
||||
}
|
||||
?>
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Setup Trash Folder & Metadata ---
|
||||
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!file_exists($trashDir)) {
|
||||
mkdir($trashDir, 0755, true);
|
||||
}
|
||||
$trashMetadataFile = $trashDir . "trash.json";
|
||||
|
||||
// Load trash metadata into an associative array keyed by trashName.
|
||||
$trashData = [];
|
||||
if (file_exists($trashMetadataFile)) {
|
||||
$json = file_get_contents($trashMetadataFile);
|
||||
$tempData = json_decode($json, true);
|
||||
if (is_array($tempData)) {
|
||||
foreach ($tempData as $item) {
|
||||
if (isset($item['trashName'])) {
|
||||
$trashData[$item['trashName']] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read request body.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$data) {
|
||||
echo json_encode(["error" => "Invalid input"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine deletion mode: if "deleteAll" is true, delete all trash items; otherwise, use provided "files" array.
|
||||
$filesToDelete = [];
|
||||
if (isset($data['deleteAll']) && $data['deleteAll'] === true) {
|
||||
$filesToDelete = array_keys($trashData);
|
||||
} elseif (isset($data['files']) && is_array($data['files'])) {
|
||||
$filesToDelete = $data['files'];
|
||||
} else {
|
||||
echo json_encode(["error" => "No trash file identifiers provided"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$deletedFiles = [];
|
||||
$errors = [];
|
||||
|
||||
// Define a safe file name pattern.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
foreach ($filesToDelete as $trashName) {
|
||||
$trashName = trim($trashName);
|
||||
if (!preg_match($safeFileNamePattern, $trashName)) {
|
||||
$errors[] = "$trashName has an invalid format.";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($trashData[$trashName])) {
|
||||
$errors[] = "Trash item $trashName not found.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $trashDir . $trashName;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
if (unlink($filePath)) {
|
||||
$deletedFiles[] = $trashName;
|
||||
unset($trashData[$trashName]);
|
||||
} else {
|
||||
$errors[] = "Failed to delete $trashName.";
|
||||
}
|
||||
} else {
|
||||
// If the file doesn't exist, remove its metadata entry.
|
||||
unset($trashData[$trashName]);
|
||||
$deletedFiles[] = $trashName;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the updated trash metadata back (as an indexed array).
|
||||
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT));
|
||||
|
||||
if (empty($errors)) {
|
||||
echo json_encode(["success" => "Trash items deleted: " . implode(", ", $deletedFiles)]);
|
||||
} else {
|
||||
echo json_encode(["error" => implode("; ", $errors) . ". Trash items deleted: " . implode(", ", $deletedFiles)]);
|
||||
}
|
||||
exit;
|
||||
?>
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
filerise:
|
||||
# Use the published image (does NOT build in CI by default)
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
restart: unless-stopped
|
||||
|
||||
# If someone wants to build locally instead, they can uncomment:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
|
||||
ports:
|
||||
- "${HOST_HTTP_PORT:-8080}:80"
|
||||
# Uncomment if you really terminate TLS inside the container:
|
||||
# - "${HOST_HTTPS_PORT:-8443}:443"
|
||||
|
||||
environment:
|
||||
TIMEZONE: "${TIMEZONE:-UTC}"
|
||||
DATE_TIME_FORMAT: "${DATE_TIME_FORMAT:-m/d/y h:iA}"
|
||||
TOTAL_UPLOAD_SIZE: "${TOTAL_UPLOAD_SIZE:-5G}"
|
||||
SECURE: "${SECURE:-false}"
|
||||
PERSISTENT_TOKENS_KEY: "${PERSISTENT_TOKENS_KEY:-please_change_this_@@}"
|
||||
PUID: "${PUID:-1000}"
|
||||
PGID: "${PGID:-1000}"
|
||||
CHOWN_ON_START: "${CHOWN_ON_START:-true}"
|
||||
SCAN_ON_START: "${SCAN_ON_START:-true}"
|
||||
SHARE_URL: "${SHARE_URL:-}"
|
||||
|
||||
volumes:
|
||||
- ./data/uploads:/var/www/uploads
|
||||
- ./data/users:/var/www/users
|
||||
- ./data/metadata:/var/www/metadata
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
283
domUtils.js
283
domUtils.js
@@ -1,283 +0,0 @@
|
||||
// domUtils.js
|
||||
|
||||
// Basic DOM Helpers
|
||||
export function toggleVisibility(elementId, shouldShow) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.style.display = shouldShow ? "block" : "none";
|
||||
} else {
|
||||
console.error(`Element with id "${elementId}" not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHTML(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function toggleAllCheckboxes(masterCheckbox) {
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox");
|
||||
checkboxes.forEach(chk => {
|
||||
chk.checked = masterCheckbox.checked;
|
||||
});
|
||||
updateFileActionButtons(); // update buttons based on current selection
|
||||
}
|
||||
|
||||
export function updateFileActionButtons() {
|
||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
const copyBtn = document.getElementById("copySelectedBtn");
|
||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||
const zipBtn = document.getElementById("downloadZipBtn");
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
|
||||
if (fileCheckboxes.length === 0) {
|
||||
if (copyBtn) copyBtn.style.display = "none";
|
||||
if (moveBtn) moveBtn.style.display = "none";
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
if (zipBtn) zipBtn.style.display = "none";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
||||
} else {
|
||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
||||
|
||||
const anySelected = selectedCheckboxes.length > 0;
|
||||
if (copyBtn) copyBtn.disabled = !anySelected;
|
||||
if (moveBtn) moveBtn.disabled = !anySelected;
|
||||
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
||||
if (zipBtn) zipBtn.disabled = !anySelected;
|
||||
|
||||
if (extractZipBtn) {
|
||||
// Enable only if at least one selected file ends with .zip (case-insensitive).
|
||||
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
|
||||
chk.value.toLowerCase().endsWith(".zip")
|
||||
);
|
||||
extractZipBtn.disabled = !anyZipSelected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function showToast(message, duration = 3000) {
|
||||
const toast = document.getElementById("customToast");
|
||||
if (!toast) {
|
||||
console.error("Toast element not found");
|
||||
return;
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.style.display = "block";
|
||||
// Force reflow for transition effect.
|
||||
void toast.offsetWidth;
|
||||
toast.classList.add("show");
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => {
|
||||
toast.style.display = "none";
|
||||
}, 500);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// --- DOM Building Functions for File Table ---
|
||||
|
||||
export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
|
||||
const safeSearchTerm = escapeHTML(searchTerm);
|
||||
return `
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-12 col-md-8 mb-2 mb-md-0">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text" id="searchIcon">
|
||||
<i class="material-icons">search</i>
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="Search files or tag..." value="${safeSearchTerm}" aria-describedby="searchIcon">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 text-left">
|
||||
<div class="d-flex justify-content-center justify-content-md-start align-items-center">
|
||||
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">Prev</button>
|
||||
<span class="page-indicator">Page ${currentPage} of ${totalPages || 1}</span>
|
||||
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildFileTableHeader(sortOrder) {
|
||||
return `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th>
|
||||
<th data-column="name" class="sortable-col">File Name ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="modified" class="hide-small sortable-col">Date Modified ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">Upload Date ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="size" class="hide-small sortable-col">File Size ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploader" class="hide-small hide-medium sortable-col">Uploader ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildFileTableRow(file, folderPath) {
|
||||
const safeFileName = escapeHTML(file.name);
|
||||
const safeModified = escapeHTML(file.modified);
|
||||
const safeUploaded = escapeHTML(file.uploaded);
|
||||
const safeSize = escapeHTML(file.size);
|
||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||
|
||||
let previewButton = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
||||
let previewIcon = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">image</i>`;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||
} else if (/\.pdf$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||
}
|
||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
|
||||
</td>
|
||||
<td class="file-name-cell">${safeFileName}</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||
<td class="hide-small nowrap">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td>
|
||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
||||
<a class="btn btn-sm btn-success download-btn"
|
||||
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}"
|
||||
title="Download">
|
||||
<i class="material-icons">file_download</i>
|
||||
</a>
|
||||
${file.editable ? `
|
||||
<button class="btn btn-sm edit-btn"
|
||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="Edit">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
` : ""}
|
||||
${previewButton}
|
||||
<button class="btn btn-sm btn-warning rename-btn"
|
||||
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="Rename">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildBottomControls(itemsPerPageSetting) {
|
||||
return `
|
||||
<div class="d-flex align-items-center mt-3 bottom-controls">
|
||||
<label class="label-inline mr-2 mb-0">Show</label>
|
||||
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)">
|
||||
${[10, 20, 50, 100].map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`).join("")}
|
||||
</select>
|
||||
<span class="items-per-page-text ml-2 mb-0">items per page</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Global Helper Functions ---
|
||||
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRowHighlight(checkbox) {
|
||||
const row = checkbox.closest('tr');
|
||||
if (!row) return;
|
||||
if (checkbox.checked) {
|
||||
row.classList.add('row-selected');
|
||||
} else {
|
||||
row.classList.remove('row-selected');
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleRowSelection(event, fileName) {
|
||||
const targetTag = event.target.tagName.toLowerCase();
|
||||
if (targetTag === 'a' || targetTag === 'button' || targetTag === 'input') {
|
||||
return;
|
||||
}
|
||||
const row = event.currentTarget;
|
||||
const checkbox = row.querySelector('.file-checkbox');
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateRowHighlight(checkbox);
|
||||
updateFileActionButtons();
|
||||
}
|
||||
|
||||
export function attachEnterKeyListener(modalId, buttonId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
// Make the modal focusable
|
||||
modal.setAttribute("tabindex", "-1");
|
||||
modal.focus();
|
||||
modal.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById(buttonId);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function showCustomConfirmModal(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
const messageElem = document.getElementById("confirmMessage");
|
||||
const yesBtn = document.getElementById("confirmYesBtn");
|
||||
const noBtn = document.getElementById("confirmNoBtn");
|
||||
|
||||
messageElem.textContent = message;
|
||||
modal.style.display = "block";
|
||||
|
||||
// Cleanup function to hide the modal and remove event listeners.
|
||||
function cleanup() {
|
||||
modal.style.display = "none";
|
||||
yesBtn.removeEventListener("click", onYes);
|
||||
noBtn.removeEventListener("click", onNo);
|
||||
}
|
||||
|
||||
function onYes() {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
}
|
||||
function onNo() {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
yesBtn.addEventListener("click", onYes);
|
||||
noBtn.addEventListener("click", onNo);
|
||||
});
|
||||
}
|
||||
89
download.php
89
download.php
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get file parameters from the GET request.
|
||||
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
|
||||
// Validate file name (allowing letters, numbers, underscores, dashes, dots, and parentheses)
|
||||
if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $file)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get the realpath of the upload directory.
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($uploadDirReal === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Server misconfiguration."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine the directory.
|
||||
if ($folder === 'root') {
|
||||
$directory = $uploadDirReal;
|
||||
} else {
|
||||
// Prevent path traversal in folder parameter.
|
||||
if (strpos($folder, '..') !== false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$directoryPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
$directory = realpath($directoryPath);
|
||||
|
||||
// Ensure that the resolved directory exists and is within the allowed UPLOAD_DIR.
|
||||
if ($directory === false || strpos($directory, $uploadDirReal) !== 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder path."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the file path.
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
// Validate that the real file path exists and is within the allowed directory.
|
||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Access forbidden."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($realFilePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "File not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve the file.
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// For images, serve inline; for other types, force download.
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) {
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
|
||||
// Disable caching.
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
?>
|
||||
133
downloadZip.php
133
downloadZip.php
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read and decode the JSON input.
|
||||
$rawData = file_get_contents("php://input");
|
||||
$data = json_decode($rawData, true);
|
||||
|
||||
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = $data['folder'];
|
||||
$files = $data['files'];
|
||||
|
||||
// Validate folder name to allow subfolders.
|
||||
// "root" is allowed; otherwise, split by "/" and validate each segment.
|
||||
if ($folder !== "root") {
|
||||
$parts = explode('/', $folder);
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$relativePath = "";
|
||||
}
|
||||
|
||||
// Use the absolute UPLOAD_DIR from config.php.
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Uploads directory not configured correctly."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||
$folderPathReal = realpath($folderPath);
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Folder not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($files)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "No files specified."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($files as $fileName) {
|
||||
if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $fileName)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Invalid file name: " . $fileName]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Build an array of files to include in the ZIP.
|
||||
$filesToZip = [];
|
||||
foreach ($files as $fileName) {
|
||||
$filePath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
|
||||
if (file_exists($filePath)) {
|
||||
$filesToZip[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($filesToZip)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "No valid files found to zip."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create a temporary file for the ZIP archive.
|
||||
$tempZip = tempnam(sys_get_temp_dir(), 'zip');
|
||||
unlink($tempZip); // Remove the temporary file so ZipArchive can create a new one.
|
||||
$tempZip .= '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tempZip, ZipArchive::CREATE) !== TRUE) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => "Could not create zip archive."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add each file to the archive using its base name.
|
||||
foreach ($filesToZip as $filePath) {
|
||||
$zip->addFile($filePath, basename($filePath));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
// Send headers to force download and disable caching.
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="files.zip"');
|
||||
header('Content-Length: ' . filesize($tempZip));
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
// Output the file and delete it afterward.
|
||||
readfile($tempZip);
|
||||
unlink($tempZip);
|
||||
exit;
|
||||
?>
|
||||
599
dragAndDrop.js
599
dragAndDrop.js
@@ -1,599 +0,0 @@
|
||||
// dragAndDrop.js
|
||||
// This file handles drag-and-drop functionality for cards in the sidebar, header and top drop zones.
|
||||
// It also manages the visibility of the sidebar and header drop zones based on the current state of the application.
|
||||
// It includes functions to save and load the order of cards in the sidebar and header from localStorage.
|
||||
// It also includes functions to handle the drag-and-drop events, including mouse movements and drop zones.
|
||||
// It uses CSS classes to manage the appearance of the sidebar and header drop zones during drag-and-drop operations.
|
||||
|
||||
// Moves cards into the sidebar based on the saved order in localStorage.
|
||||
export function loadSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const orderStr = localStorage.getItem('sidebarOrder');
|
||||
if (orderStr) {
|
||||
const order = JSON.parse(orderStr);
|
||||
if (order.length > 0) {
|
||||
// Ensure main wrapper is visible.
|
||||
const mainWrapper = document.querySelector('.main-wrapper');
|
||||
if (mainWrapper) {
|
||||
mainWrapper.style.display = 'flex';
|
||||
}
|
||||
// For each saved ID, move the card into the sidebar.
|
||||
order.forEach(id => {
|
||||
const card = document.getElementById(id);
|
||||
if (card && card.parentNode.id !== 'sidebarDropArea') {
|
||||
sidebar.appendChild(card);
|
||||
// Animate vertical slide for sidebar card
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
updateSidebarVisibility();
|
||||
}
|
||||
|
||||
// NEW: Load header order from localStorage.
|
||||
export function loadHeaderOrder() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!headerDropArea) return;
|
||||
const orderStr = localStorage.getItem('headerOrder');
|
||||
if (orderStr) {
|
||||
const order = JSON.parse(orderStr);
|
||||
if (order.length > 0) {
|
||||
order.forEach(id => {
|
||||
const card = document.getElementById(id);
|
||||
// Only load if card is not already in header drop zone.
|
||||
if (card && card.parentNode.id !== 'headerDropArea') {
|
||||
insertCardInHeader(card, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper: update sidebar visibility based on its content.
|
||||
function updateSidebarVisibility() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
if (cards.length > 0) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
} else {
|
||||
sidebar.classList.remove('active');
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
// Save the current order in localStorage.
|
||||
saveSidebarOrder();
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Save header order to localStorage.
|
||||
function saveHeaderOrder() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
const icons = Array.from(headerDropArea.children);
|
||||
// Each header icon stores its associated card in the property cardElement.
|
||||
const order = icons.map(icon => icon.cardElement.id);
|
||||
localStorage.setItem('headerOrder', JSON.stringify(order));
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper: update top zone layout (center a card if one column is empty).
|
||||
function updateTopZoneLayout() {
|
||||
const leftCol = document.getElementById('leftCol');
|
||||
const rightCol = document.getElementById('rightCol');
|
||||
|
||||
const leftIsEmpty = !leftCol.querySelector('#uploadCard');
|
||||
const rightIsEmpty = !rightCol.querySelector('#folderManagementCard');
|
||||
|
||||
if (leftIsEmpty && !rightIsEmpty) {
|
||||
leftCol.style.display = 'none';
|
||||
rightCol.style.margin = '0 auto';
|
||||
} else if (rightIsEmpty && !leftIsEmpty) {
|
||||
rightCol.style.display = 'none';
|
||||
leftCol.style.margin = '0 auto';
|
||||
} else {
|
||||
leftCol.style.display = '';
|
||||
rightCol.style.display = '';
|
||||
leftCol.style.margin = '';
|
||||
rightCol.style.margin = '';
|
||||
}
|
||||
}
|
||||
|
||||
// When a card is being dragged, if the top drop zone is empty, set its min-height.
|
||||
function addTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.add('highlight');
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
topZone.style.minHeight = '375px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the drag ends, remove the extra min-height.
|
||||
function removeTopZoneHighlight() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (topZone) {
|
||||
topZone.classList.remove('highlight');
|
||||
topZone.style.minHeight = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical slide/fade animation helper.
|
||||
function animateVerticalSlide(card) {
|
||||
card.style.transform = 'translateY(30px)';
|
||||
card.style.opacity = '0';
|
||||
// Force reflow.
|
||||
card.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.opacity = '1';
|
||||
});
|
||||
setTimeout(() => {
|
||||
card.style.transition = '';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
}, 310);
|
||||
}
|
||||
|
||||
// Internal helper: insert card into sidebar at a proper position based on event.clientY.
|
||||
function insertCardInSidebar(card, event) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const existingCards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
let inserted = false;
|
||||
for (const currentCard of existingCards) {
|
||||
const rect = currentCard.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (event.clientY < midY) {
|
||||
sidebar.insertBefore(card, currentCard);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inserted) {
|
||||
sidebar.appendChild(card);
|
||||
}
|
||||
// Ensure card fills the sidebar.
|
||||
card.style.width = '100%';
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
|
||||
// Internal helper: save the current sidebar card order to localStorage.
|
||||
function saveSidebarOrder() {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
const cards = sidebar.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
const order = Array.from(cards).map(card => card.id);
|
||||
localStorage.setItem('sidebarOrder', JSON.stringify(order));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: move cards from sidebar back to the top drop area when on small screens.
|
||||
function moveSidebarCardsToTop() {
|
||||
if (window.innerWidth < 1205) {
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (!sidebar) return;
|
||||
const cards = Array.from(sidebar.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
cards.forEach(card => {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
animateVerticalSlide(card);
|
||||
}
|
||||
});
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for window resize to automatically move sidebar cards back to top on small screens.
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth < 1205) {
|
||||
moveSidebarCardsToTop();
|
||||
}
|
||||
});
|
||||
|
||||
// This function ensures the top drop zone (#uploadFolderRow) has a stable width when empty.
|
||||
function ensureTopZonePlaceholder() {
|
||||
const topZone = document.getElementById('uploadFolderRow');
|
||||
if (!topZone) return;
|
||||
if (topZone.querySelectorAll('#uploadCard, #folderManagementCard').length === 0) {
|
||||
let placeholder = topZone.querySelector('.placeholder');
|
||||
if (!placeholder) {
|
||||
placeholder = document.createElement('div');
|
||||
placeholder.className = 'placeholder';
|
||||
placeholder.style.visibility = 'hidden';
|
||||
placeholder.style.display = 'block';
|
||||
placeholder.style.width = '100%';
|
||||
placeholder.style.height = '375px';
|
||||
topZone.appendChild(placeholder);
|
||||
}
|
||||
} else {
|
||||
const placeholder = topZone.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW HELPER FUNCTIONS FOR HEADER DROP ZONE ---
|
||||
|
||||
// Show header drop zone and add a "drag-active" class so that the pseudo-element appears.
|
||||
function showHeaderDropZone() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
headerDropArea.style.display = 'inline-flex';
|
||||
headerDropArea.classList.add('drag-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide header drop zone by removing the "drag-active" class.
|
||||
// If a header icon is present (i.e. a card was dropped), the drop zone remains visible without the dashed border.
|
||||
function hideHeaderDropZone() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (headerDropArea) {
|
||||
headerDropArea.classList.remove('drag-active');
|
||||
if (headerDropArea.children.length === 0) {
|
||||
headerDropArea.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === NEW FUNCTION: Insert card into header drop zone as a material icon ===
|
||||
function insertCardInHeader(card, event) {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!headerDropArea) return;
|
||||
|
||||
// For folder management and upload cards, preserve the original by moving it to a hidden container.
|
||||
if (card.id === 'folderManagementCard' || card.id === 'uploadCard') {
|
||||
let hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (!hiddenContainer) {
|
||||
hiddenContainer = document.createElement('div');
|
||||
hiddenContainer.id = 'hiddenCardsContainer';
|
||||
hiddenContainer.style.display = 'none';
|
||||
document.body.appendChild(hiddenContainer);
|
||||
}
|
||||
// Move the original card to the hidden container if it's not already there.
|
||||
if (card.parentNode.id !== 'hiddenCardsContainer') {
|
||||
hiddenContainer.appendChild(card);
|
||||
}
|
||||
} else {
|
||||
// For other cards, simply remove from current container.
|
||||
if (card.parentNode) {
|
||||
card.parentNode.removeChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the header icon button.
|
||||
const iconButton = document.createElement('button');
|
||||
iconButton.className = 'header-card-icon';
|
||||
// Remove default button styling.
|
||||
iconButton.style.border = 'none';
|
||||
iconButton.style.background = 'none';
|
||||
iconButton.style.outline = 'none';
|
||||
iconButton.style.cursor = 'pointer';
|
||||
|
||||
// Choose an icon based on the card type with 24px size.
|
||||
if (card.id === 'uploadCard') {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">cloud_upload</i>';
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">folder</i>';
|
||||
} else {
|
||||
iconButton.innerHTML = '<i class="material-icons" style="font-size:24px;">insert_drive_file</i>';
|
||||
}
|
||||
|
||||
// Save a reference to the card in the icon button.
|
||||
iconButton.cardElement = card;
|
||||
// Associate this icon with the card for future removal.
|
||||
card.headerIconButton = iconButton;
|
||||
|
||||
let modal = null;
|
||||
let isLocked = false;
|
||||
let hoverActive = false;
|
||||
|
||||
// showModal: When triggered, ensure the card is attached to the modal.
|
||||
function showModal() {
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'header-card-modal';
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.top = '80px';
|
||||
modal.style.right = '80px';
|
||||
modal.style.zIndex = '11000';
|
||||
// Render the modal but initially keep it hidden.
|
||||
modal.style.display = 'block';
|
||||
modal.style.visibility = 'hidden';
|
||||
modal.style.opacity = '0';
|
||||
modal.style.background = 'none';
|
||||
modal.style.border = 'none';
|
||||
modal.style.padding = '0';
|
||||
modal.style.boxShadow = 'none';
|
||||
document.body.appendChild(modal);
|
||||
// Attach modal hover events.
|
||||
modal.addEventListener('mouseover', handleMouseOver);
|
||||
modal.addEventListener('mouseout', handleMouseOut);
|
||||
iconButton.modalInstance = modal;
|
||||
}
|
||||
// If the card isn't already in the modal, remove it from the hidden container and attach it.
|
||||
if (!modal.contains(card)) {
|
||||
const hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (hiddenContainer && hiddenContainer.contains(card)) {
|
||||
hiddenContainer.removeChild(card);
|
||||
}
|
||||
modal.appendChild(card);
|
||||
}
|
||||
// Reveal the modal.
|
||||
modal.style.visibility = 'visible';
|
||||
modal.style.opacity = '1';
|
||||
}
|
||||
|
||||
// hideModal: Hide the modal and return the card to the hidden container.
|
||||
function hideModal() {
|
||||
if (modal && !isLocked && !hoverActive) {
|
||||
modal.style.visibility = 'hidden';
|
||||
modal.style.opacity = '0';
|
||||
// Return the card to the hidden container.
|
||||
const hiddenContainer = document.getElementById('hiddenCardsContainer');
|
||||
if (hiddenContainer && modal.contains(card)) {
|
||||
hiddenContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseOver() {
|
||||
hoverActive = true;
|
||||
showModal();
|
||||
}
|
||||
|
||||
function handleMouseOut() {
|
||||
hoverActive = false;
|
||||
setTimeout(() => {
|
||||
if (!hoverActive && !isLocked) {
|
||||
hideModal();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Attach hover events to the icon.
|
||||
iconButton.addEventListener('mouseover', handleMouseOver);
|
||||
iconButton.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
// Toggle the locked state on click so the modal stays open.
|
||||
iconButton.addEventListener('click', (e) => {
|
||||
isLocked = !isLocked;
|
||||
if (isLocked) {
|
||||
showModal();
|
||||
} else {
|
||||
hideModal();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Append the header icon button into the header drop zone.
|
||||
headerDropArea.appendChild(iconButton);
|
||||
// Save the updated header order.
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
// === Main Drag and Drop Initialization ===
|
||||
export function initDragAndDrop() {
|
||||
function run() {
|
||||
const draggableCards = document.querySelectorAll('#uploadCard, #folderManagementCard');
|
||||
draggableCards.forEach(card => {
|
||||
if (!card.dataset.originalContainerId) {
|
||||
card.dataset.originalContainerId = card.parentNode.id;
|
||||
}
|
||||
const header = card.querySelector('.card-header');
|
||||
if (header) {
|
||||
header.classList.add('drag-header');
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let dragTimer = null;
|
||||
let offsetX = 0, offsetY = 0;
|
||||
let initialLeft, initialTop;
|
||||
|
||||
if (header) {
|
||||
header.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
const card = this.closest('.card');
|
||||
// Capture the card's initial bounding rectangle.
|
||||
const initialRect = card.getBoundingClientRect();
|
||||
const originX = ((e.clientX - initialRect.left) / initialRect.width) * 100;
|
||||
const originY = ((e.clientY - initialRect.top) / initialRect.height) * 100;
|
||||
card.style.transformOrigin = `${originX}% ${originY}%`;
|
||||
|
||||
// Store the initial rect so we use it later.
|
||||
dragTimer = setTimeout(() => {
|
||||
isDragging = true;
|
||||
card.classList.add('dragging');
|
||||
card.style.pointerEvents = 'none';
|
||||
addTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('active');
|
||||
sidebar.style.display = 'block';
|
||||
sidebar.classList.add('highlight');
|
||||
sidebar.style.height = '800px';
|
||||
}
|
||||
|
||||
// Show header drop zone while dragging.
|
||||
showHeaderDropZone();
|
||||
|
||||
// Use the stored initialRect.
|
||||
initialLeft = initialRect.left + window.pageXOffset;
|
||||
initialTop = initialRect.top + window.pageYOffset;
|
||||
offsetX = e.pageX - initialLeft;
|
||||
offsetY = e.pageY - initialTop;
|
||||
|
||||
// Remove any associated header icon if present.
|
||||
if (card.headerIconButton) {
|
||||
if (card.headerIconButton.parentNode) {
|
||||
card.headerIconButton.parentNode.removeChild(card.headerIconButton);
|
||||
}
|
||||
if (card.headerIconButton.modalInstance && card.headerIconButton.modalInstance.parentNode) {
|
||||
card.headerIconButton.modalInstance.parentNode.removeChild(card.headerIconButton.modalInstance);
|
||||
}
|
||||
card.headerIconButton = null;
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
// Append card to body and fix its dimensions.
|
||||
document.body.appendChild(card);
|
||||
card.style.position = 'absolute';
|
||||
card.style.left = initialLeft + 'px';
|
||||
card.style.top = initialTop + 'px';
|
||||
card.style.width = initialRect.width + 'px';
|
||||
card.style.height = initialRect.height + 'px';
|
||||
card.style.minWidth = initialRect.width + 'px';
|
||||
card.style.flexShrink = '0';
|
||||
card.style.zIndex = '10000';
|
||||
}, 500);
|
||||
});
|
||||
header.addEventListener('mouseup', function () {
|
||||
clearTimeout(dragTimer);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (isDragging) {
|
||||
card.style.left = (e.pageX - offsetX) + 'px';
|
||||
card.style.top = (e.pageY - offsetY) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function (e) {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
card.style.pointerEvents = '';
|
||||
card.classList.remove('dragging');
|
||||
removeTopZoneHighlight();
|
||||
|
||||
const sidebar = document.getElementById('sidebarDropArea');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('highlight');
|
||||
sidebar.style.height = '';
|
||||
}
|
||||
|
||||
// Remove any existing header icon if present.
|
||||
if (card.headerIconButton) {
|
||||
if (card.headerIconButton.parentNode) {
|
||||
card.headerIconButton.parentNode.removeChild(card.headerIconButton);
|
||||
}
|
||||
if (card.headerIconButton.modalInstance && card.headerIconButton.modalInstance.parentNode) {
|
||||
card.headerIconButton.modalInstance.parentNode.removeChild(card.headerIconButton.modalInstance);
|
||||
}
|
||||
card.headerIconButton = null;
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
let droppedInSidebar = false;
|
||||
let droppedInTop = false;
|
||||
let droppedInHeader = false;
|
||||
|
||||
// Check if dropped in sidebar drop zone.
|
||||
const sidebarElem = document.getElementById('sidebarDropArea');
|
||||
if (sidebarElem) {
|
||||
const rect = sidebarElem.getBoundingClientRect();
|
||||
const dropZoneBottom = rect.top + 800; // Virtual drop zone height.
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= dropZoneBottom
|
||||
) {
|
||||
insertCardInSidebar(card, e);
|
||||
droppedInSidebar = true;
|
||||
}
|
||||
}
|
||||
// Check the top drop zone.
|
||||
const topRow = document.getElementById('uploadFolderRow');
|
||||
if (!droppedInSidebar && topRow) {
|
||||
const rect = topRow.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
let container;
|
||||
if (card.id === 'uploadCard') {
|
||||
container = document.getElementById('leftCol');
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
container = document.getElementById('rightCol');
|
||||
}
|
||||
if (container) {
|
||||
ensureTopZonePlaceholder();
|
||||
updateTopZoneLayout();
|
||||
container.appendChild(card);
|
||||
droppedInTop = true;
|
||||
// Set a fixed width during animation.
|
||||
card.style.width = "363px";
|
||||
animateVerticalSlide(card);
|
||||
setTimeout(() => {
|
||||
card.style.removeProperty('width');
|
||||
}, 210);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check the header drop zone.
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!droppedInSidebar && !droppedInTop && headerDropArea) {
|
||||
const rect = headerDropArea.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
insertCardInHeader(card, e);
|
||||
droppedInHeader = true;
|
||||
}
|
||||
}
|
||||
// If card was not dropped in any zone, return it to its original container.
|
||||
if (!droppedInSidebar && !droppedInTop && !droppedInHeader) {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
card.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear inline drag-related styles.
|
||||
[
|
||||
'position',
|
||||
'left',
|
||||
'top',
|
||||
'z-index',
|
||||
'height',
|
||||
'min-width',
|
||||
'flex-shrink',
|
||||
'transition',
|
||||
'transform',
|
||||
'opacity'
|
||||
].forEach(prop => card.style.removeProperty(prop));
|
||||
|
||||
// For sidebar drops, force width to 100%.
|
||||
if (droppedInSidebar) {
|
||||
card.style.width = '100%';
|
||||
}
|
||||
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
|
||||
// Hide header drop zone if no icon is present.
|
||||
hideHeaderDropZone();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
165
extractZip.php
165
extractZip.php
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to extract zip files"]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Read and decode the JSON input.
|
||||
$rawData = file_get_contents("php://input");
|
||||
$data = json_decode($rawData, true);
|
||||
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = $data['folder'];
|
||||
$files = $data['files'];
|
||||
|
||||
if (empty($files)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "No files specified."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate folder name (allow "root" or valid subfolder names).
|
||||
if ($folder !== "root") {
|
||||
$parts = explode('/', $folder);
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$relativePath = "";
|
||||
}
|
||||
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Uploads directory not configured correctly."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||
$folderPathReal = realpath($folderPath);
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Folder not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- Metadata Setup ----------
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
$srcMetaFile = getMetadataFilePath($folder);
|
||||
$destMetaFile = getMetadataFilePath($folder);
|
||||
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
|
||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
||||
|
||||
$errors = [];
|
||||
$allSuccess = true;
|
||||
$extractedFiles = array(); // Array to collect names of extracted files
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
// ---------- Process Each File ----------
|
||||
foreach ($files as $zipFileName) {
|
||||
$originalName = basename(trim($zipFileName));
|
||||
// Process only .zip files.
|
||||
if (strtolower(substr($originalName, -4)) !== '.zip') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match($safeFileNamePattern, $originalName)) {
|
||||
$errors[] = "$originalName has an invalid name.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName;
|
||||
if (!file_exists($zipFilePath)) {
|
||||
$errors[] = "$originalName does not exist in folder.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath) !== TRUE) {
|
||||
$errors[] = "Could not open $originalName as a zip file.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt extraction.
|
||||
if (!$zip->extractTo($folderPathReal)) {
|
||||
$errors[] = "Failed to extract $originalName.";
|
||||
$allSuccess = false;
|
||||
} else {
|
||||
// Collect extracted file names from this zip.
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
$extractedFileName = basename($entryName);
|
||||
if ($extractedFileName) {
|
||||
$extractedFiles[] = $extractedFileName;
|
||||
}
|
||||
}
|
||||
// Update metadata for each extracted file if the zip file has metadata.
|
||||
if (isset($srcMetadata[$originalName])) {
|
||||
$zipMeta = $srcMetadata[$originalName];
|
||||
// Iterate through all entries in the zip.
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
$extractedFileName = basename($entryName);
|
||||
if ($extractedFileName) {
|
||||
$destMetadata[$extractedFileName] = $zipMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
// Write updated metadata back to the destination metadata file.
|
||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
||||
$errors[] = "Failed to update metadata.";
|
||||
$allSuccess = false;
|
||||
}
|
||||
|
||||
if ($allSuccess) {
|
||||
echo json_encode(["success" => true, "extractedFiles" => $extractedFiles]);
|
||||
} else {
|
||||
echo json_encode(["success" => false, "error" => implode(" ", $errors)]);
|
||||
}
|
||||
exit;
|
||||
?>
|
||||
1609
fileManager.js
1609
fileManager.js
File diff suppressed because it is too large
Load Diff
466
fileTags.js
466
fileTags.js
@@ -1,466 +0,0 @@
|
||||
// fileTags.js
|
||||
// This module provides functions for opening the tag modal,
|
||||
// adding tags to files (with a global tag store for reuse),
|
||||
// updating the file row display with tag badges,
|
||||
// filtering the file list by tag, and persisting tag data.
|
||||
import { escapeHTML } from './domUtils.js';
|
||||
|
||||
export function openTagModal(file) {
|
||||
// Create the modal element.
|
||||
let modal = document.createElement('div');
|
||||
modal.id = 'tagModal';
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="width: 400px; max-width:90vw;">
|
||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0;">Tag File: ${file.name}</h3>
|
||||
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="tagNameInput">Tag Name:</label>
|
||||
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<label for="tagColorInput">Tag Color:</label>
|
||||
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
||||
<!-- Custom tag options will be populated here -->
|
||||
</div>
|
||||
<br>
|
||||
<div style="text-align:right;">
|
||||
<button id="saveTagBtn" class="btn btn-primary">Save Tag</button>
|
||||
</div>
|
||||
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
|
||||
<!-- Existing tags will be listed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
|
||||
updateCustomTagDropdown();
|
||||
|
||||
document.getElementById('closeTagModal').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
updateTagModalDisplay(file);
|
||||
|
||||
document.getElementById('tagNameInput').addEventListener('input', (e) => {
|
||||
updateCustomTagDropdown(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('saveTagBtn').addEventListener('click', () => {
|
||||
const tagName = document.getElementById('tagNameInput').value.trim();
|
||||
const tagColor = document.getElementById('tagColorInput').value;
|
||||
if (!tagName) {
|
||||
alert('Please enter a tag name.');
|
||||
return;
|
||||
}
|
||||
addTagToFile(file, { name: tagName, color: tagColor });
|
||||
updateTagModalDisplay(file);
|
||||
updateFileRowTagDisplay(file);
|
||||
saveFileTags(file);
|
||||
document.getElementById('tagNameInput').value = '';
|
||||
updateCustomTagDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal to tag multiple files.
|
||||
* @param {Array} files - Array of file objects to tag.
|
||||
*/
|
||||
export function openMultiTagModal(files) {
|
||||
let modal = document.createElement('div');
|
||||
modal.id = 'multiTagModal';
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="width: 400px; max-width:90vw;">
|
||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
||||
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="multiTagNameInput">Tag Name:</label>
|
||||
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<label for="multiTagColorInput">Tag Color:</label>
|
||||
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||
<br><br>
|
||||
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
||||
<!-- Custom tag options will be populated here -->
|
||||
</div>
|
||||
<br>
|
||||
<div style="text-align:right;">
|
||||
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
|
||||
updateMultiCustomTagDropdown();
|
||||
|
||||
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
|
||||
updateMultiCustomTagDropdown(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
|
||||
const tagName = document.getElementById('multiTagNameInput').value.trim();
|
||||
const tagColor = document.getElementById('multiTagColorInput').value;
|
||||
if (!tagName) {
|
||||
alert('Please enter a tag name.');
|
||||
return;
|
||||
}
|
||||
files.forEach(file => {
|
||||
addTagToFile(file, { name: tagName, color: tagColor });
|
||||
updateFileRowTagDisplay(file);
|
||||
saveFileTags(file);
|
||||
});
|
||||
modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the custom dropdown for multi-tag modal.
|
||||
* Similar to updateCustomTagDropdown but includes a remove icon.
|
||||
*/
|
||||
function updateMultiCustomTagDropdown(filterText = "") {
|
||||
const dropdown = document.getElementById("multiCustomTagDropdown");
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = "";
|
||||
let tags = window.globalTags || [];
|
||||
if (filterText) {
|
||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
tags.forEach(tag => {
|
||||
const item = document.createElement("div");
|
||||
item.style.cursor = "pointer";
|
||||
item.style.padding = "5px";
|
||||
item.style.borderBottom = "1px solid #eee";
|
||||
// Display colored square and tag name with remove icon.
|
||||
item.innerHTML = `
|
||||
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
||||
${escapeHTML(tag.name)}
|
||||
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
|
||||
`;
|
||||
item.addEventListener("click", function(e) {
|
||||
if (e.target.classList.contains("global-remove")) return;
|
||||
document.getElementById("multiTagNameInput").value = tag.name;
|
||||
document.getElementById("multiTagColorInput").value = tag.color;
|
||||
});
|
||||
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
removeGlobalTag(tag.name);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function updateCustomTagDropdown(filterText = "") {
|
||||
const dropdown = document.getElementById("customTagDropdown");
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = "";
|
||||
let tags = window.globalTags || [];
|
||||
if (filterText) {
|
||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
tags.forEach(tag => {
|
||||
const item = document.createElement("div");
|
||||
item.style.cursor = "pointer";
|
||||
item.style.padding = "5px";
|
||||
item.style.borderBottom = "1px solid #eee";
|
||||
item.innerHTML = `
|
||||
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
||||
${escapeHTML(tag.name)}
|
||||
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
|
||||
`;
|
||||
item.addEventListener("click", function(e){
|
||||
if (e.target.classList.contains('global-remove')) return;
|
||||
document.getElementById("tagNameInput").value = tag.name;
|
||||
document.getElementById("tagColorInput").value = tag.color;
|
||||
});
|
||||
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
removeGlobalTag(tag.name);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
||||
}
|
||||
}
|
||||
|
||||
// Update the modal display to show current tags on the file.
|
||||
function updateTagModalDisplay(file) {
|
||||
const container = document.getElementById('currentTags');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<strong>Current Tags:</strong> ';
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
file.tags.forEach(tag => {
|
||||
const tagElem = document.createElement('span');
|
||||
tagElem.textContent = tag.name;
|
||||
tagElem.style.backgroundColor = tag.color;
|
||||
tagElem.style.color = '#fff';
|
||||
tagElem.style.padding = '2px 6px';
|
||||
tagElem.style.marginRight = '5px';
|
||||
tagElem.style.borderRadius = '3px';
|
||||
tagElem.style.display = 'inline-block';
|
||||
tagElem.style.position = 'relative';
|
||||
|
||||
const removeIcon = document.createElement('span');
|
||||
removeIcon.textContent = ' ✕';
|
||||
removeIcon.style.fontWeight = 'bold';
|
||||
removeIcon.style.marginLeft = '3px';
|
||||
removeIcon.style.cursor = 'pointer';
|
||||
|
||||
removeIcon.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeTagFromFile(file, tag.name);
|
||||
});
|
||||
|
||||
tagElem.appendChild(removeIcon);
|
||||
container.appendChild(tagElem);
|
||||
});
|
||||
} else {
|
||||
container.innerHTML += 'None';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTagFromFile(file, tagName) {
|
||||
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
||||
updateTagModalDisplay(file);
|
||||
updateFileRowTagDisplay(file);
|
||||
saveFileTags(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag from the global tag store.
|
||||
* This function updates window.globalTags and calls the backend endpoint
|
||||
* to remove the tag from the persistent store.
|
||||
*/
|
||||
function removeGlobalTag(tagName) {
|
||||
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||
updateCustomTagDropdown();
|
||||
updateMultiCustomTagDropdown();
|
||||
saveGlobalTagRemoval(tagName);
|
||||
}
|
||||
|
||||
// NEW: Save global tag removal to the server.
|
||||
function saveGlobalTagRemoval(tagName) {
|
||||
fetch("saveFileTag.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: "root",
|
||||
file: "global",
|
||||
deleteGlobal: true,
|
||||
tagToDelete: tagName,
|
||||
tags: []
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log("Global tag removed:", tagName);
|
||||
if (data.globalTags) {
|
||||
window.globalTags = data.globalTags;
|
||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||
updateCustomTagDropdown();
|
||||
updateMultiCustomTagDropdown();
|
||||
}
|
||||
} else {
|
||||
console.error("Error removing global tag:", data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error removing global tag:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Global store for reusable tags.
|
||||
window.globalTags = window.globalTags || [];
|
||||
if (localStorage.getItem('globalTags')) {
|
||||
try {
|
||||
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// New function to load global tags from the server's persistent JSON.
|
||||
export function loadGlobalTags() {
|
||||
fetch("metadata/createdTags.json", { credentials: "include" })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
// If the file doesn't exist, assume there are no global tags.
|
||||
return [];
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
window.globalTags = data;
|
||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||
updateCustomTagDropdown();
|
||||
updateMultiCustomTagDropdown();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error loading global tags:", err);
|
||||
window.globalTags = [];
|
||||
updateCustomTagDropdown();
|
||||
updateMultiCustomTagDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
loadGlobalTags();
|
||||
|
||||
// Add (or update) a tag in the file object.
|
||||
export function addTagToFile(file, tag) {
|
||||
if (!file.tags) {
|
||||
file.tags = [];
|
||||
}
|
||||
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
||||
if (exists) {
|
||||
exists.color = tag.color;
|
||||
} else {
|
||||
file.tags.push(tag);
|
||||
}
|
||||
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
||||
if (!globalExists) {
|
||||
window.globalTags.push(tag);
|
||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the file row (in table view) to show tag badges.
|
||||
export function updateFileRowTagDisplay(file) {
|
||||
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
||||
console.log('Updating tags for rows:', rows);
|
||||
rows.forEach(row => {
|
||||
let cell = row.querySelector('.file-name-cell');
|
||||
if (cell) {
|
||||
let badgeContainer = cell.querySelector('.tag-badges');
|
||||
if (!badgeContainer) {
|
||||
badgeContainer = document.createElement('div');
|
||||
badgeContainer.className = 'tag-badges';
|
||||
badgeContainer.style.display = 'inline-block';
|
||||
badgeContainer.style.marginLeft = '5px';
|
||||
cell.appendChild(badgeContainer);
|
||||
}
|
||||
badgeContainer.innerHTML = '';
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
file.tags.forEach(tag => {
|
||||
const badge = document.createElement('span');
|
||||
badge.textContent = tag.name;
|
||||
badge.style.backgroundColor = tag.color;
|
||||
badge.style.color = '#fff';
|
||||
badge.style.padding = '2px 4px';
|
||||
badge.style.marginRight = '2px';
|
||||
badge.style.borderRadius = '3px';
|
||||
badge.style.fontSize = '0.8em';
|
||||
badge.style.verticalAlign = 'middle';
|
||||
badgeContainer.appendChild(badge);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initTagSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
let tagSearchInput = document.getElementById('tagSearchInput');
|
||||
if (!tagSearchInput) {
|
||||
tagSearchInput = document.createElement('input');
|
||||
tagSearchInput.id = 'tagSearchInput';
|
||||
tagSearchInput.placeholder = 'Filter by tag';
|
||||
tagSearchInput.style.marginLeft = '10px';
|
||||
tagSearchInput.style.padding = '5px';
|
||||
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
||||
tagSearchInput.addEventListener('input', () => {
|
||||
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
||||
if (window.currentFolder) {
|
||||
renderFileTable(window.currentFolder);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterFilesByTag(files) {
|
||||
if (window.currentTagFilter && window.currentTagFilter !== '') {
|
||||
return files.filter(file => {
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function updateGlobalTagList() {
|
||||
const dataList = document.getElementById("globalTagList");
|
||||
if (dataList) {
|
||||
dataList.innerHTML = "";
|
||||
window.globalTags.forEach(tag => {
|
||||
const option = document.createElement("option");
|
||||
option.value = tag.name;
|
||||
dataList.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
||||
const folder = file.folder || "root";
|
||||
const payload = {
|
||||
folder: folder,
|
||||
file: file.name,
|
||||
tags: file.tags
|
||||
};
|
||||
if (deleteGlobal && tagToDelete) {
|
||||
payload.file = "global";
|
||||
payload.deleteGlobal = true;
|
||||
payload.tagToDelete = tagToDelete;
|
||||
}
|
||||
fetch("saveFileTag.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log("Tags saved:", data);
|
||||
if (data.globalTags) {
|
||||
window.globalTags = data.globalTags;
|
||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||
updateCustomTagDropdown();
|
||||
updateMultiCustomTagDropdown();
|
||||
}
|
||||
} else {
|
||||
console.error("Error saving tags:", data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error saving tags:", err);
|
||||
});
|
||||
}
|
||||
749
folderManager.js
749
folderManager.js
@@ -1,749 +0,0 @@
|
||||
// folderManager.js
|
||||
|
||||
import { loadFileList } from './fileManager.js';
|
||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
||||
|
||||
/* ----------------------
|
||||
Helper Functions (Data/State)
|
||||
----------------------*/
|
||||
|
||||
// Formats a folder name for display (e.g. adding indentations).
|
||||
export function formatFolderName(folder) {
|
||||
if (typeof folder !== "string") return "";
|
||||
if (folder.indexOf("/") !== -1) {
|
||||
let parts = folder.split("/");
|
||||
let indent = "";
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level
|
||||
}
|
||||
return indent + parts[parts.length - 1];
|
||||
} else {
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a tree structure from a flat array of folder paths.
|
||||
function buildFolderTree(folders) {
|
||||
const tree = {};
|
||||
folders.forEach(folderPath => {
|
||||
if (typeof folderPath !== "string") return;
|
||||
const parts = folderPath.split('/');
|
||||
let current = tree;
|
||||
parts.forEach(part => {
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part];
|
||||
});
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Folder Tree State (Save/Load)
|
||||
----------------------*/
|
||||
function loadFolderTreeState() {
|
||||
const state = localStorage.getItem("folderTreeState");
|
||||
return state ? JSON.parse(state) : {};
|
||||
}
|
||||
|
||||
function saveFolderTreeState(state) {
|
||||
localStorage.setItem("folderTreeState", JSON.stringify(state));
|
||||
}
|
||||
|
||||
// Helper for getting the parent folder.
|
||||
function getParentFolder(folder) {
|
||||
if (folder === "root") return "root";
|
||||
const lastSlash = folder.lastIndexOf("/");
|
||||
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
function renderBreadcrumb(normalizedFolder) {
|
||||
if (!normalizedFolder || normalizedFolder === "") return "";
|
||||
const parts = normalizedFolder.split("/");
|
||||
let breadcrumbItems = [];
|
||||
// Use the first segment as the root.
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
|
||||
let cumulative = parts[0];
|
||||
parts.slice(1).forEach(part => {
|
||||
cumulative += "/" + part;
|
||||
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
|
||||
});
|
||||
return breadcrumbItems.join('');
|
||||
}
|
||||
|
||||
function bindBreadcrumbEvents() {
|
||||
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbLinks.forEach(link => {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
let folder = this.getAttribute("data-folder");
|
||||
window.currentFolder = folder;
|
||||
localStorage.setItem("lastOpenedFolder", folder);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(folder) + ")";
|
||||
expandTreePath(folder);
|
||||
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (targetOption) targetOption.classList.add("selected");
|
||||
loadFileList(folder);
|
||||
bindBreadcrumbEvents();
|
||||
});
|
||||
link.addEventListener("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.add("drop-hover");
|
||||
});
|
||||
link.addEventListener("dragleave", function (e) {
|
||||
this.classList.remove("drop-hover");
|
||||
});
|
||||
link.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove("drop-hover");
|
||||
const dropFolder = this.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
} catch (err) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
return;
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("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: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving files via drop on breadcrumb:", error);
|
||||
showToast("Error moving files.");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Check Current User's Folder-Only Permission
|
||||
----------------------*/
|
||||
// This function uses localStorage values (set during login) to determine if the current user is restricted.
|
||||
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root.
|
||||
function checkUserFolderPermission() {
|
||||
const username = localStorage.getItem("username");
|
||||
console.log("checkUserFolderPermission: username =", username);
|
||||
if (!username) {
|
||||
console.warn("No username in localStorage; skipping getUserPermissions fetch.");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (localStorage.getItem("folderOnly") === "true") {
|
||||
window.userFolderOnly = true;
|
||||
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
|
||||
localStorage.setItem("lastOpenedFolder", username);
|
||||
window.currentFolder = username;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return fetch("getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
|
||||
if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) {
|
||||
window.userFolderOnly = true;
|
||||
localStorage.setItem("folderOnly", "true");
|
||||
localStorage.setItem("lastOpenedFolder", username);
|
||||
window.currentFolder = username;
|
||||
return true;
|
||||
} else {
|
||||
window.userFolderOnly = false;
|
||||
localStorage.setItem("folderOnly", "false");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error fetching user permissions:", err);
|
||||
window.userFolderOnly = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
DOM Building Functions for Folder Tree
|
||||
----------------------*/
|
||||
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
const state = loadFolderTreeState();
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||
for (const folder in tree) {
|
||||
if (folder.toLowerCase() === "trash") continue;
|
||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||
html += `<li class="folder-item">`;
|
||||
if (hasChildren) {
|
||||
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']';
|
||||
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
|
||||
} else {
|
||||
html += `<span class="folder-indent-placeholder"></span>`;
|
||||
}
|
||||
html += `<span class="folder-option" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
||||
if (hasChildren) {
|
||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
}
|
||||
html += `</li>`;
|
||||
}
|
||||
html += `</ul>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function expandTreePath(path) {
|
||||
const parts = path.split("/");
|
||||
let cumulative = "";
|
||||
parts.forEach((part, index) => {
|
||||
cumulative = index === 0 ? part : cumulative + "/" + part;
|
||||
const option = document.querySelector(`.folder-option[data-folder="${cumulative}"]`);
|
||||
if (option) {
|
||||
const li = option.parentNode;
|
||||
const nestedUl = li.querySelector("ul");
|
||||
if (nestedUl && (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded"))) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
const toggle = li.querySelector(".folder-toggle");
|
||||
if (toggle) {
|
||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
let state = loadFolderTreeState();
|
||||
state[cumulative] = "block";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Drag & Drop Support for Folder Tree Nodes
|
||||
----------------------*/
|
||||
function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
}
|
||||
|
||||
function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
}
|
||||
|
||||
function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data", e);
|
||||
return;
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("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: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving files via drop:", error);
|
||||
showToast("Error moving files.");
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Main Folder Tree Rendering and Event Binding
|
||||
----------------------*/
|
||||
export async function loadFolderTree(selectedFolder) {
|
||||
try {
|
||||
// Check if the user has folder-only permission.
|
||||
await checkUserFolderPermission();
|
||||
|
||||
// Determine effective root folder.
|
||||
const username = localStorage.getItem("username") || "root";
|
||||
let effectiveRoot = "root";
|
||||
let effectiveLabel = "(Root)";
|
||||
if (window.userFolderOnly) {
|
||||
effectiveRoot = username; // Use the username as the personal root.
|
||||
effectiveLabel = `(Root)`;
|
||||
// Force override of any saved folder.
|
||||
localStorage.setItem("lastOpenedFolder", username);
|
||||
window.currentFolder = username;
|
||||
} else {
|
||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||
}
|
||||
|
||||
// Build fetch URL.
|
||||
let fetchUrl = 'getFolderList.php';
|
||||
if (window.userFolderOnly) {
|
||||
fetchUrl += '?restricted=1';
|
||||
}
|
||||
console.log("Fetching folder list from:", fetchUrl);
|
||||
|
||||
// Fetch folder list from the server.
|
||||
const response = await fetch(fetchUrl);
|
||||
if (response.status === 401) {
|
||||
console.error("Unauthorized: Please log in to view folders.");
|
||||
showToast("Session expired. Please log in again.");
|
||||
window.location.href = "logout.php";
|
||||
return;
|
||||
}
|
||||
let folderData = await response.json();
|
||||
console.log("Folder data received:", folderData);
|
||||
let folders = [];
|
||||
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
|
||||
folders = folderData.map(item => item.folder);
|
||||
} else if (Array.isArray(folderData)) {
|
||||
folders = folderData;
|
||||
}
|
||||
|
||||
// Remove any global "root" entry.
|
||||
folders = folders.filter(folder => folder.toLowerCase() !== "root");
|
||||
|
||||
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
|
||||
if (window.userFolderOnly && effectiveRoot !== "root") {
|
||||
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
||||
// Force current folder to be the effective root.
|
||||
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
||||
window.currentFolder = effectiveRoot;
|
||||
}
|
||||
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
// Render the folder tree.
|
||||
const container = document.getElementById("folderTreeContainer");
|
||||
if (!container) {
|
||||
console.error("Folder tree container not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div id="rootRow" class="root-row">
|
||||
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
|
||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
|
||||
</div>`;
|
||||
if (folders.length > 0) {
|
||||
const tree = buildFolderTree(folders);
|
||||
html += renderFolderTree(tree, "", "block");
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach drag/drop event listeners.
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
});
|
||||
|
||||
if (selectedFolder) {
|
||||
window.currentFolder = selectedFolder;
|
||||
}
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
bindBreadcrumbEvents();
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
|
||||
expandTreePath(window.currentFolder);
|
||||
}
|
||||
|
||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||
if (selectedEl) {
|
||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
selectedEl.classList.add("selected");
|
||||
}
|
||||
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
el.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
this.classList.add("selected");
|
||||
const selected = this.getAttribute("data-folder");
|
||||
window.currentFolder = selected;
|
||||
localStorage.setItem("lastOpenedFolder", selected);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(selected) + ")";
|
||||
bindBreadcrumbEvents();
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
|
||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||
if (rootToggle) {
|
||||
rootToggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const nestedUl = container.querySelector("#rootRow + ul");
|
||||
if (nestedUl) {
|
||||
let state = loadFolderTreeState();
|
||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[effectiveRoot] = "block";
|
||||
} else {
|
||||
nestedUl.classList.remove("expanded");
|
||||
nestedUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[effectiveRoot] = "none";
|
||||
}
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const siblingUl = this.parentNode.querySelector("ul");
|
||||
const folderPath = this.getAttribute("data-folder");
|
||||
let state = loadFolderTreeState();
|
||||
if (siblingUl) {
|
||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||
siblingUl.classList.remove("collapsed");
|
||||
siblingUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[folderPath] = "block";
|
||||
} else {
|
||||
siblingUl.classList.remove("expanded");
|
||||
siblingUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[folderPath] = "none";
|
||||
}
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading folder tree:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility.
|
||||
export function loadFolderList(selectedFolder) {
|
||||
loadFolderTree(selectedFolder);
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Folder Management (Rename, Delete, Create)
|
||||
----------------------*/
|
||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
||||
|
||||
function openRenameFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast("Please select a valid folder to rename.");
|
||||
return;
|
||||
}
|
||||
const parts = selectedFolder.split("/");
|
||||
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
|
||||
document.getElementById("renameFolderModal").style.display = "block";
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("newRenameFolderName");
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
|
||||
document.getElementById("renameFolderModal").style.display = "none";
|
||||
document.getElementById("newRenameFolderName").value = "";
|
||||
});
|
||||
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
||||
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
|
||||
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
||||
showToast("Please enter a valid new folder name.");
|
||||
return;
|
||||
}
|
||||
const parentPath = getParentFolder(selectedFolder);
|
||||
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
if (!csrfToken) {
|
||||
showToast("CSRF token not loaded yet! Please try again.");
|
||||
return;
|
||||
}
|
||||
fetch("renameFolder.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Folder renamed successfully!");
|
||||
window.currentFolder = newFolderFull;
|
||||
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
||||
loadFolderList(newFolderFull);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not rename folder"));
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error renaming folder:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("renameFolderModal").style.display = "none";
|
||||
document.getElementById("newRenameFolderName").value = "";
|
||||
});
|
||||
});
|
||||
|
||||
function openDeleteFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast("Please select a valid folder to delete.");
|
||||
return;
|
||||
}
|
||||
document.getElementById("deleteFolderMessage").textContent =
|
||||
"Are you sure you want to delete folder " + selectedFolder + "?";
|
||||
document.getElementById("deleteFolderModal").style.display = "block";
|
||||
}
|
||||
|
||||
document.getElementById("cancelDeleteFolder").addEventListener("click", function () {
|
||||
document.getElementById("deleteFolderModal").style.display = "none";
|
||||
});
|
||||
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
|
||||
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
fetch("deleteFolder.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({ folder: selectedFolder })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Folder deleted successfully!");
|
||||
window.currentFolder = getParentFolder(selectedFolder);
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
loadFolderList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not delete folder"));
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error deleting folder:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("deleteFolderModal").style.display = "none";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
});
|
||||
|
||||
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
document.getElementById("newFolderName").value = "";
|
||||
});
|
||||
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||
document.getElementById("submitCreateFolder").addEventListener("click", function () {
|
||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
||||
if (!folderInput) {
|
||||
showToast("Please enter a folder name.");
|
||||
return;
|
||||
}
|
||||
let selectedFolder = window.currentFolder || "root";
|
||||
let fullFolderName = folderInput;
|
||||
if (selectedFolder && selectedFolder !== "root") {
|
||||
fullFolderName = selectedFolder + "/" + folderInput;
|
||||
}
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
fetch("createFolder.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folderName: folderInput,
|
||||
parent: selectedFolder === "root" ? "" : selectedFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Folder created successfully!");
|
||||
window.currentFolder = fullFolderName;
|
||||
localStorage.setItem("lastOpenedFolder", fullFolderName);
|
||||
loadFolderList(fullFolderName);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not create folder"));
|
||||
}
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
document.getElementById("newFolderName").value = "";
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error creating folder:", error);
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||
function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("folderManagerContextMenu");
|
||||
if (!menu) {
|
||||
menu = document.createElement("div");
|
||||
menu.id = "folderManagerContextMenu";
|
||||
menu.style.position = "absolute";
|
||||
menu.style.padding = "5px 0";
|
||||
menu.style.minWidth = "150px";
|
||||
menu.style.zIndex = "9999";
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menu.style.backgroundColor = "#2c2c2c";
|
||||
menu.style.border = "1px solid #555";
|
||||
menu.style.color = "#e0e0e0";
|
||||
} else {
|
||||
menu.style.backgroundColor = "#fff";
|
||||
menu.style.border = "1px solid #ccc";
|
||||
menu.style.color = "#000";
|
||||
}
|
||||
menu.innerHTML = "";
|
||||
menuItems.forEach(item => {
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = "5px 15px";
|
||||
menuItem.style.cursor = "pointer";
|
||||
menuItem.addEventListener("mouseover", () => {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menuItem.style.backgroundColor = "#444";
|
||||
} else {
|
||||
menuItem.style.backgroundColor = "#f0f0f0";
|
||||
}
|
||||
});
|
||||
menuItem.addEventListener("mouseout", () => {
|
||||
menuItem.style.backgroundColor = "";
|
||||
});
|
||||
menuItem.addEventListener("click", () => {
|
||||
item.action();
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
function hideFolderManagerContextMenu() {
|
||||
const menu = document.getElementById("folderManagerContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function folderManagerContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
window.currentFolder = folder;
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
const menuItems = [
|
||||
{
|
||||
label: "Create Folder",
|
||||
action: () => {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Rename Folder",
|
||||
action: () => { openRenameFolderModal(); }
|
||||
},
|
||||
{
|
||||
label: "Delete Folder",
|
||||
action: () => { openDeleteFolderModal(); }
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
}
|
||||
|
||||
function bindFolderManagerContextMenu() {
|
||||
const container = document.getElementById("folderTreeContainer");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
}
|
||||
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbNodes.forEach(node => {
|
||||
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
node.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function () {
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("keydown", function (e) {
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||
if (window.currentFolder && window.currentFolder !== "root") {
|
||||
e.preventDefault();
|
||||
openDeleteFolderModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bindFolderManagerContextMenu();
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$configFile = USERS_DIR . 'adminConfig.json';
|
||||
if (file_exists($configFile)) {
|
||||
$encryptedContent = file_get_contents($configFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($decryptedContent === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to decrypt configuration.']);
|
||||
exit;
|
||||
}
|
||||
// Decode the configuration and ensure globalOtpauthUrl is set
|
||||
$config = json_decode($decryptedContent, true);
|
||||
if (!isset($config['globalOtpauthUrl'])) {
|
||||
$config['globalOtpauthUrl'] = "";
|
||||
}
|
||||
echo json_encode($config);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => 'YOUR_CLIENT_ID',
|
||||
'clientSecret' => 'YOUR_CLIENT_SECRET',
|
||||
'redirectUri' => 'https://yourdomain.com/auth.php?oidc=callback'
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => false
|
||||
],
|
||||
'globalOtpauthUrl' => ""
|
||||
]);
|
||||
}
|
||||
?>
|
||||
106
getFileList.php
106
getFileList.php
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
// Allow only safe characters in the folder parameter (letters, numbers, underscores, dashes, spaces, and forward slashes).
|
||||
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine the directory based on the folder parameter.
|
||||
if ($folder !== 'root') {
|
||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
} else {
|
||||
$directory = UPLOAD_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate the metadata file path for a given folder.
|
||||
* For "root", returns "root_metadata.json". Otherwise, replaces slashes,
|
||||
* backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
*
|
||||
* @param string $folder The folder's relative path.
|
||||
* @return string The full path to the folder's metadata file.
|
||||
*/
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
$metadataFile = getMetadataFilePath($folder);
|
||||
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
echo json_encode(["error" => "Directory not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$files = array_values(array_diff(scandir($directory), array('.', '..')));
|
||||
$fileList = [];
|
||||
|
||||
// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces.
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Skip hidden files (those that begin with a dot)
|
||||
if (substr($file, 0, 1) === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
// Only include files (skip directories)
|
||||
if (!is_file($filePath)) continue;
|
||||
|
||||
// Optionally, skip files with unsafe names.
|
||||
if (!preg_match($safeFileNamePattern, $file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Since metadata is stored per folder, the key is simply the file name.
|
||||
$metaKey = $file;
|
||||
|
||||
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
|
||||
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
||||
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
||||
|
||||
$fileSizeBytes = filesize($filePath);
|
||||
if ($fileSizeBytes >= 1073741824) {
|
||||
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
||||
} elseif ($fileSizeBytes >= 1048576) {
|
||||
$fileSizeFormatted = sprintf("%.1f MB", $fileSizeBytes / 1048576);
|
||||
} elseif ($fileSizeBytes >= 1024) {
|
||||
$fileSizeFormatted = sprintf("%.1f KB", $fileSizeBytes / 1024);
|
||||
} else {
|
||||
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
||||
}
|
||||
|
||||
$fileList[] = [
|
||||
'name' => $file,
|
||||
'modified' => $fileDateModified,
|
||||
'uploaded' => $fileUploadedDate,
|
||||
'size' => $fileSizeFormatted,
|
||||
'uploader' => $fileUploader,
|
||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
||||
];
|
||||
}
|
||||
|
||||
// Load global tags from createdTags.json.
|
||||
$globalTagsFile = META_DIR . "createdTags.json";
|
||||
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
|
||||
|
||||
echo json_encode(["files" => $fileList, "globalTags" => $globalTags]);
|
||||
?>
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for subfolders.
|
||||
*
|
||||
* @param string $dir The full path to the directory.
|
||||
* @param string $relative The relative path from the base upload directory.
|
||||
* @return array An array of folder paths (relative to the base).
|
||||
*/
|
||||
function getSubfolders($dir, $relative = '') {
|
||||
$folders = [];
|
||||
$items = scandir($dir);
|
||||
// Allow letters, numbers, underscores, dashes, and spaces in folder names.
|
||||
$safeFolderNamePattern = '/^[A-Za-z0-9_\- ]+$/';
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
if (!preg_match($safeFolderNamePattern, $item)) {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_dir($path)) {
|
||||
// Build the relative path.
|
||||
$folderPath = ($relative ? $relative . '/' : '') . $item;
|
||||
$folders[] = $folderPath;
|
||||
// Recursively get subfolders.
|
||||
$subFolders = getSubfolders($path, $folderPath);
|
||||
$folders = array_merge($folders, $subFolders);
|
||||
}
|
||||
}
|
||||
return $folders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate the metadata file path for a given folder.
|
||||
* For "root", it returns "root_metadata.json"; otherwise, it replaces
|
||||
* slashes, backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
*
|
||||
* @param string $folder The folder's relative path.
|
||||
* @return string The full path to the folder's metadata file.
|
||||
*/
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
|
||||
// Build an array to hold folder information.
|
||||
$folderInfoList = [];
|
||||
|
||||
// Include "root" as a folder.
|
||||
$rootMetaFile = getMetadataFilePath('root');
|
||||
$rootFileCount = 0;
|
||||
if (file_exists($rootMetaFile)) {
|
||||
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
|
||||
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
|
||||
}
|
||||
$folderInfoList[] = [
|
||||
"folder" => "root",
|
||||
"fileCount" => $rootFileCount,
|
||||
"metadataFile" => basename($rootMetaFile)
|
||||
];
|
||||
|
||||
// Scan for subfolders.
|
||||
$subfolders = [];
|
||||
if (is_dir($baseDir)) {
|
||||
$subfolders = getSubfolders($baseDir);
|
||||
}
|
||||
|
||||
// For each subfolder, load its metadata and record file count.
|
||||
foreach ($subfolders as $folder) {
|
||||
$metaFile = getMetadataFilePath($folder);
|
||||
$fileCount = 0;
|
||||
if (file_exists($metaFile)) {
|
||||
$metadata = json_decode(file_get_contents($metaFile), true);
|
||||
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
||||
}
|
||||
$folderInfoList[] = [
|
||||
"folder" => $folder,
|
||||
"fileCount" => $fileCount,
|
||||
"metadataFile" => basename($metaFile)
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($folderInfoList);
|
||||
?>
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Define the trash directory and trash metadata file.
|
||||
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$trashMetadataFile = $trashDir . "trash.json";
|
||||
|
||||
// Helper: Generate the metadata file path for a given folder.
|
||||
// For "root", returns "root_metadata.json". Otherwise, replaces slashes, backslashes, and spaces with dashes and appends "_metadata.json".
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
// Read the trash metadata.
|
||||
$trashItems = [];
|
||||
if (file_exists($trashMetadataFile)) {
|
||||
$json = file_get_contents($trashMetadataFile);
|
||||
$trashItems = json_decode($json, true);
|
||||
if (!is_array($trashItems)) {
|
||||
$trashItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich each trash record.
|
||||
foreach ($trashItems as &$item) {
|
||||
// Ensure deletedBy is set and not empty.
|
||||
if (empty($item['deletedBy'])) {
|
||||
$item['deletedBy'] = "Unknown";
|
||||
}
|
||||
// Enrich with uploader and uploaded date if not already present.
|
||||
if (empty($item['uploaded']) || empty($item['uploader'])) {
|
||||
if (isset($item['originalFolder']) && isset($item['originalName'])) {
|
||||
$metadataFile = getMetadataFilePath($item['originalFolder']);
|
||||
if (file_exists($metadataFile)) {
|
||||
$metadata = json_decode(file_get_contents($metadataFile), true);
|
||||
if (is_array($metadata) && isset($metadata[$item['originalName']])) {
|
||||
$item['uploaded'] = !empty($metadata[$item['originalName']]['uploaded']) ? $metadata[$item['originalName']]['uploaded'] : "Unknown";
|
||||
$item['uploader'] = !empty($metadata[$item['originalName']]['uploader']) ? $metadata[$item['originalName']]['uploader'] : "Unknown";
|
||||
} else {
|
||||
$item['uploaded'] = "Unknown";
|
||||
$item['uploader'] = "Unknown";
|
||||
}
|
||||
} else {
|
||||
$item['uploaded'] = "Unknown";
|
||||
$item['uploader'] = "Unknown";
|
||||
}
|
||||
} else {
|
||||
$item['uploaded'] = "Unknown";
|
||||
$item['uploader'] = "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
echo json_encode($trashItems);
|
||||
exit;
|
||||
?>
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check if the user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
$permissionsArray = [];
|
||||
|
||||
// Load permissions file if it exists.
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
// Attempt to decrypt the content.
|
||||
$decryptedContent = decryptData($content, $encryptionKey);
|
||||
if ($decryptedContent === false) {
|
||||
// If decryption fails, assume the file is plain JSON.
|
||||
$permissionsArray = json_decode($content, true);
|
||||
} else {
|
||||
$permissionsArray = json_decode($decryptedContent, true);
|
||||
}
|
||||
if (!is_array($permissionsArray)) {
|
||||
$permissionsArray = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is an admin, return all permissions.
|
||||
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
|
||||
echo json_encode($permissionsArray);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Otherwise, return only the current user's permissions.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
foreach ($permissionsArray as $storedUsername => $data) {
|
||||
if (strcasecmp($storedUsername, $username) === 0) {
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// If no permissions are found for the current user, return an empty object.
|
||||
echo json_encode(new stdClass());
|
||||
?>
|
||||
31
getUsers.php
31
getUsers.php
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$users = [];
|
||||
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if (count($parts) >= 3) {
|
||||
// Validate username format:
|
||||
if (preg_match('/^[A-Za-z0-9_\- ]+$/', $parts[0])) {
|
||||
$users[] = [
|
||||
"username" => $parts[0],
|
||||
"role" => trim($parts[2])
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($users);
|
||||
?>
|
||||
401
index.html
401
index.html
@@ -1,401 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<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="">
|
||||
<!-- 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 href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"></script>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="header-container">
|
||||
<div class="header-left">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
<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="logoutBtn" title="Logout">
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
</button>
|
||||
<button id="changePasswordBtn" title="Change Password" style="display: none;">
|
||||
<i class="material-icons">vpn_key</i>
|
||||
</button>
|
||||
<div id="restoreFilesModal" class="modal centered-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h4 class="custom-restore-header">
|
||||
<i class="material-icons orange-icon">restore_from_trash</i>
|
||||
<span>Restore or</span>
|
||||
<i class="material-icons red-icon">delete_for_ever</i>
|
||||
<span>Delete Trash Items</span>
|
||||
</h4>
|
||||
<div id="restoreFilesList"
|
||||
style="max-height:300px; overflow-y:auto; border:1px solid #ccc; padding:10px; margin-bottom:10px;">
|
||||
<!-- Trash items will be loaded here -->
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<button id="restoreSelectedBtn" class="btn btn-primary">Restore Selected</button>
|
||||
<button id="restoreAllBtn" class="btn btn-secondary">Restore All</button>
|
||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning">Delete Selected</button>
|
||||
<button id="deleteAllBtn" class="btn btn-danger">Delete All</button>
|
||||
<button id="closeRestoreModal" class="btn btn-dark">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="addUserBtn" title="Add User" style="display: none;">
|
||||
<i class="material-icons">person_add</i>
|
||||
</button>
|
||||
<button id="removeUserBtn" title="Remove User" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="dark-mode-toggle">Dark Mode</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
|
||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
||||
<!-- Main Column -->
|
||||
<div id="mainColumn" class="main-column">
|
||||
<div class="container-fluid">
|
||||
<!-- Login Form (unchanged) -->
|
||||
<div class="row" id="loginForm">
|
||||
<div class="col-12">
|
||||
<form id="authForm" method="post">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">User:</label>
|
||||
<input type="text" class="form-control" id="loginUsername" name="username" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">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">Login</button>
|
||||
<div class="form-group remember-me-container">
|
||||
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
||||
<label for="rememberMeCheckbox">Remember me</label>
|
||||
</div>
|
||||
</form>
|
||||
<!-- OIDC Login Option -->
|
||||
<div class="text-center mt-3">
|
||||
<button id="oidcLoginBtn" class="btn btn-secondary">Login with OIDC</button>
|
||||
</div>
|
||||
<!-- Basic HTTP Login Option -->
|
||||
<div class="text-center mt-3">
|
||||
<a href="login_basic.php" class="btn btn-secondary">Use Basic HTTP Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Operations: Upload and Folder Management -->
|
||||
<div id="mainOperations">
|
||||
<div class="container" style="max-width: 1400px; margin: 0 auto;">
|
||||
<!-- Top Zone: Two columns (60% and 40%) -->
|
||||
<div id="uploadFolderRow" class="row">
|
||||
<!-- Left Column (60% for Upload Card) -->
|
||||
<div id="leftCol" class="col-md-7" style="display: flex; justify-content: center;">
|
||||
<div id="uploadCard" class="card" style="width: 100%;">
|
||||
<div class="card-header">Upload Files/Folders</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
||||
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
|
||||
<div id="uploadDropArea"
|
||||
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
||||
<span>Drop files/folders here or click 'Choose Files'</span>
|
||||
<br />
|
||||
<input type="file" id="file" name="file[]" class="form-control-file" multiple
|
||||
style="opacity:0; position:absolute; width:1px; height:1px;" />
|
||||
<button type="button" id="customChooseBtn">Choose Files</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto">Upload</button>
|
||||
<div id="uploadProgressContainer"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Column (40% for Folder Management Card) -->
|
||||
<div id="rightCol" class="col-md-5" style="display: flex; justify-content: center;">
|
||||
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>Folder Navigation & Management</span>
|
||||
<button id="folderHelpBtn" class="btn btn-link" title="Folder Help"
|
||||
style="padding: 0; border: none; background: none;">
|
||||
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body custom-folder-card-body">
|
||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||
<div id="folderTreeContainer"></div>
|
||||
</div>
|
||||
<div class="folder-actions mt-3">
|
||||
<button id="createFolderBtn" class="btn btn-primary">Create Folder</button>
|
||||
<div id="createFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Create Folder</h4>
|
||||
<input type="text" id="newFolderName" class="form-control" placeholder="Enter folder name"
|
||||
style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelCreateFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitCreateFolder" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="renameFolderBtn" class="btn btn-secondary ml-2" title="Rename Folder">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<div id="renameFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Rename Folder</h4>
|
||||
<input type="text" id="newRenameFolderName" class="form-control"
|
||||
placeholder="Enter new folder name" style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitRenameFolder" class="btn btn-primary">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="deleteFolderBtn" class="btn btn-danger ml-2" title="Delete Folder">
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
<div id="deleteFolderModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Delete Folder</h4>
|
||||
<p id="deleteFolderMessage">Are you sure you want to delete this folder?</p>
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDeleteFolder" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDeleteFolder" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
||||
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
|
||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
||||
<li>Click on a folder in the tree to view its files.</li>
|
||||
<li>Use [-] to collapse and [+] to expand folders.</li>
|
||||
<li>Select a folder and click "Create Folder" to add a subfolder.</li>
|
||||
<li>To rename or delete a folder, select it and then click the appropriate button.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end uploadFolderRow -->
|
||||
</div> <!-- end container -->
|
||||
</div> <!-- end mainOperations -->
|
||||
|
||||
<!-- File List Section -->
|
||||
<div id="fileListContainer" style="display: none;">
|
||||
<h2 id="fileListTitle">Files in (Root)</h2>
|
||||
<div id="fileListActions" class="file-list-actions">
|
||||
<button id="deleteSelectedBtn" class="btn action-btn" style="display: none;">Delete Files</button>
|
||||
<div id="deleteFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Delete Selected Files</h4>
|
||||
<p id="deleteFilesMessage">Are you sure you want to delete the selected files?</p>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelDeleteFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDeleteFiles" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="copySelectedBtn" class="btn action-btn" style="display: none;" disabled>Copy Files</button>
|
||||
<div id="copyFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Copy Selected Files</h4>
|
||||
<p id="copyFilesMessage">Select a target folder for copying the selected files:</p>
|
||||
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelCopyFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmCopyFiles" class="btn btn-primary">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="moveSelectedBtn" class="btn action-btn" style="display: none;" disabled>Move Files</button>
|
||||
<div id="moveFilesModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Move Selected Files</h4>
|
||||
<p id="moveFilesMessage">Select a target folder for moving the selected files:</p>
|
||||
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelMoveFiles" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmMoveFiles" class="btn btn-primary">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled>Download ZIP</button>
|
||||
<button id="extractZipBtn" class="btn btn-sm btn-info" title="Extract Zip">Extract Zip</button>
|
||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h4>Download Selected Files as Zip</h4>
|
||||
<p>Enter a name for the zip file:</p>
|
||||
<input type="text" id="zipFileNameInput" class="form-control" placeholder="files.zip" />
|
||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelDownloadZip" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmDownloadZip" class="btn btn-primary">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileList"></div>
|
||||
</div>
|
||||
</div> <!-- end container-fluid -->
|
||||
</div> <!-- end mainColumn -->
|
||||
</div> <!-- end main-wrapper -->
|
||||
|
||||
<!-- 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;">
|
||||
<span id="closeChangePasswordModal" style="cursor:pointer;">×</span>
|
||||
<h3>Change Password</h3>
|
||||
<input type="password" id="oldPassword" placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="newPassword" placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||
<input type="password" id="confirmPassword" placeholder="Confirm New Password"
|
||||
style="width:100%; margin: 5px 0;" />
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" style="width:100%;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Create New User</h3>
|
||||
<label for="newUsername">Username:</label>
|
||||
<input type="text" id="newUsername" class="form-control" />
|
||||
<label for="addUserPassword">Password:</label>
|
||||
<input type="password" id="addUserPassword" class="form-control" />
|
||||
<div id="adminCheckboxContainer">
|
||||
<input type="checkbox" id="isAdmin" />
|
||||
<label for="isAdmin">Grant Admin Access</label>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button id="cancelUserBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="saveUserBtn" class="btn btn-primary">Save User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="removeUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Remove User</h3>
|
||||
<label for="removeUsernameSelect">Select a user to remove:</label>
|
||||
<select id="removeUsernameSelect" class="form-control"></select>
|
||||
<div class="button-container">
|
||||
<button id="cancelRemoveUserBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="deleteUserBtn" class="btn btn-danger">Delete User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="renameFileModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h4>Rename File</h4>
|
||||
<input type="text" id="newFileName" class="form-control" placeholder="Enter new file name"
|
||||
style="margin-top:10px;" />
|
||||
<div style="margin-top:15px; text-align:right;">
|
||||
<button id="cancelRenameFile" class="btn btn-secondary">Cancel</button>
|
||||
<button id="submitRenameFile" class="btn btn-primary">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="customConfirmModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirmYesBtn" class="btn btn-primary">Yes</button>
|
||||
<button id="confirmNoBtn" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5
licenses/NOTICE_GOOGLE_FONTS.txt
Normal file
5
licenses/NOTICE_GOOGLE_FONTS.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Google Fonts & Icons NOTICE
|
||||
|
||||
This product bundles font files from Google Fonts (Roboto, Material Icons, and/or Material Symbols).
|
||||
Copyright 2012–present Google Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (see ../apache-2.0.txt).
|
||||
202
licenses/apache-2.0.txt
Normal file
202
licenses/apache-2.0.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
19
licenses/mit.txt
Normal file
19
licenses/mit.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
106
login_basic.php
106
login_basic.php
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE; // Make sure the users file path is defined
|
||||
|
||||
// Reuse the same authentication function
|
||||
function authenticate($username, $password)
|
||||
{
|
||||
global $usersFile;
|
||||
if (!file_exists($usersFile)) {
|
||||
error_log("authenticate(): users file not found");
|
||||
return false;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
list($storedUser, $storedPass, $storedRole) = explode(':', trim($line), 3);
|
||||
if ($username === $storedUser && password_verify($password, $storedPass)) {
|
||||
return $storedRole; // Return the user's role
|
||||
}
|
||||
}
|
||||
error_log("authenticate(): authentication failed for '$username'");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Define helper function to get a user's role from users.txt
|
||||
function getUserRole($username) {
|
||||
global $usersFile;
|
||||
if (file_exists($usersFile)) {
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(":", trim($line));
|
||||
if (count($parts) >= 3 && $parts[0] === $username) {
|
||||
return trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add the loadFolderPermission function here:
|
||||
function loadFolderPermission($username) {
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
if (file_exists($permissionsFile)) {
|
||||
$content = file_get_contents($permissionsFile);
|
||||
// Try to decrypt the content.
|
||||
$decryptedContent = decryptData($content, $encryptionKey);
|
||||
if ($decryptedContent !== false) {
|
||||
$permissions = json_decode($decryptedContent, true);
|
||||
} else {
|
||||
$permissions = json_decode($content, true);
|
||||
}
|
||||
if (is_array($permissions)) {
|
||||
// Use case-insensitive comparison.
|
||||
foreach ($permissions as $storedUsername => $data) {
|
||||
if (strcasecmp($storedUsername, $username) === 0 && isset($data['folderOnly'])) {
|
||||
return (bool)$data['folderOnly'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // Default if not set.
|
||||
}
|
||||
|
||||
// Check if the user has sent HTTP Basic auth credentials.
|
||||
if (!isset($_SERVER['PHP_AUTH_USER'])) {
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Authorization Required';
|
||||
exit;
|
||||
} else {
|
||||
$username = trim($_SERVER['PHP_AUTH_USER']);
|
||||
$password = trim($_SERVER['PHP_AUTH_PW']);
|
||||
|
||||
// Validate username format (optional)
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid username format';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attempt authentication
|
||||
$roleFromAuth = authenticate($username, $password);
|
||||
if ($roleFromAuth !== false) {
|
||||
// Use getUserRole() to determine the user's role from the file
|
||||
$actualRole = getUserRole($username);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($actualRole === "1");
|
||||
// Set the folderOnly flag based on userPermissions.json.
|
||||
$_SESSION["folderOnly"] = loadFolderPermission($username);
|
||||
|
||||
// Redirect to the main page (or output JSON for testing)
|
||||
header("Location: index.html");
|
||||
exit;
|
||||
} else {
|
||||
// Invalid credentials; prompt again
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid credentials';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
37
logout.php
37
logout.php
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// Retrieve headers and check CSRF token.
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
// If there's a mismatch, log it but continue with logout.
|
||||
if (isset($_SESSION['csrf_token']) && $receivedToken !== $_SESSION['csrf_token']) {
|
||||
error_log("CSRF token mismatch on logout. Proceeding with logout.");
|
||||
}
|
||||
|
||||
// If the remember me token is set, remove it from the persistent tokens file.
|
||||
if (isset($_COOKIE['remember_me_token'])) {
|
||||
$token = $_COOKIE['remember_me_token'];
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (is_array($persistentTokens) && isset($persistentTokens[$token])) {
|
||||
unset($persistentTokens[$token]);
|
||||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
|
||||
}
|
||||
}
|
||||
// Clear the cookie.
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
// Clear session data and destroy the session.
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
|
||||
header("Location: index.html");
|
||||
exit;
|
||||
?>
|
||||
191
main.js
191
main.js
@@ -1,191 +0,0 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import {
|
||||
toggleVisibility,
|
||||
toggleAllCheckboxes,
|
||||
updateFileActionButtons,
|
||||
showToast
|
||||
} from './domUtils.js';
|
||||
import {
|
||||
loadFileList,
|
||||
initFileActions,
|
||||
editFile,
|
||||
saveFile,
|
||||
displayFilePreview,
|
||||
renameFile
|
||||
} from './fileManager.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
import { initUpload } from './upload.js';
|
||||
import { initAuth, checkAuthentication } from './auth.js';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
||||
|
||||
function loadCsrfTokenWithRetry(retries = 3, delay = 1000) {
|
||||
return fetch('token.php', { credentials: 'include' })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Token fetch failed with status: " + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Set global variables.
|
||||
window.csrfToken = data.csrf_token;
|
||||
window.SHARE_URL = data.share_url;
|
||||
|
||||
// Update (or create) the CSRF meta tag.
|
||||
let metaCSRF = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!metaCSRF) {
|
||||
metaCSRF = document.createElement('meta');
|
||||
metaCSRF.name = 'csrf-token';
|
||||
document.head.appendChild(metaCSRF);
|
||||
}
|
||||
metaCSRF.setAttribute('content', data.csrf_token);
|
||||
|
||||
// Update (or create) the share URL meta tag.
|
||||
let metaShare = document.querySelector('meta[name="share-url"]');
|
||||
if (!metaShare) {
|
||||
metaShare = document.createElement('meta');
|
||||
metaShare.name = 'share-url';
|
||||
document.head.appendChild(metaShare);
|
||||
}
|
||||
metaShare.setAttribute('content', data.share_url);
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(error => {
|
||||
if (retries > 0) {
|
||||
console.warn(`CSRF token load failed. Retrying in ${delay}ms... (${retries} retries left)`, error);
|
||||
return new Promise(resolve => setTimeout(resolve, delay))
|
||||
.then(() => loadCsrfTokenWithRetry(retries - 1, delay * 2));
|
||||
}
|
||||
console.error("Failed to load CSRF token after retries.", error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Expose functions for inline handlers.
|
||||
window.sendRequest = sendRequest;
|
||||
window.toggleVisibility = toggleVisibility;
|
||||
window.toggleAllCheckboxes = toggleAllCheckboxes;
|
||||
window.editFile = editFile;
|
||||
window.saveFile = saveFile;
|
||||
window.renameFile = renameFile;
|
||||
|
||||
// Global variable for the current folder.
|
||||
window.currentFolder = "root";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// First, load the CSRF token (with retry).
|
||||
loadCsrfTokenWithRetry().then(() => {
|
||||
// Once CSRF token is loaded, initialize authentication.
|
||||
initAuth();
|
||||
|
||||
// Continue with initializations that rely on a valid CSRF token:
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
window.currentFolder = "root";
|
||||
initTagSearch();
|
||||
loadFileList(window.currentFolder);
|
||||
initDragAndDrop();
|
||||
loadSidebarOrder();
|
||||
loadHeaderOrder();
|
||||
initFileActions();
|
||||
initUpload();
|
||||
loadFolderTree();
|
||||
setupTrashRestoreDelete();
|
||||
|
||||
const helpBtn = document.getElementById("folderHelpBtn");
|
||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||
helpBtn.addEventListener("click", function () {
|
||||
// Toggle display of the tooltip.
|
||||
if (helpTooltip.style.display === "none" || helpTooltip.style.display === "") {
|
||||
helpTooltip.style.display = "block";
|
||||
} else {
|
||||
helpTooltip.style.display = "none";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn("User not authenticated. Data loading deferred.");
|
||||
}
|
||||
});
|
||||
|
||||
// Other DOM initialization that can happen after CSRF is ready.
|
||||
const newPasswordInput = document.getElementById("newPassword");
|
||||
if (newPasswordInput) {
|
||||
newPasswordInput.addEventListener("input", function() {
|
||||
console.log("newPassword input event:", this.value);
|
||||
});
|
||||
} else {
|
||||
console.error("newPassword input not found!");
|
||||
}
|
||||
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const storedDarkMode = localStorage.getItem("darkMode");
|
||||
|
||||
if (storedDarkMode === "true") {
|
||||
document.body.classList.add("dark-mode");
|
||||
} else if (storedDarkMode === "false") {
|
||||
document.body.classList.remove("dark-mode");
|
||||
} else {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.body.classList.add("dark-mode");
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
}
|
||||
}
|
||||
|
||||
if (darkModeToggle) {
|
||||
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
|
||||
? "Light Mode"
|
||||
: "Dark Mode";
|
||||
|
||||
darkModeToggle.addEventListener("click", function () {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
document.body.classList.remove("dark-mode");
|
||||
localStorage.setItem("darkMode", "false");
|
||||
darkModeToggle.textContent = "Dark Mode";
|
||||
} else {
|
||||
document.body.classList.add("dark-mode");
|
||||
localStorage.setItem("darkMode", "true");
|
||||
darkModeToggle.textContent = "Light Mode";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (localStorage.getItem("darkMode") === null && window.matchMedia) {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
|
||||
if (event.matches) {
|
||||
document.body.classList.add("dark-mode");
|
||||
if (darkModeToggle) darkModeToggle.textContent = "Light Mode";
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
if (darkModeToggle) darkModeToggle.textContent = "Dark Mode";
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- End Dark Mode Persistence ---
|
||||
|
||||
const message = sessionStorage.getItem("welcomeMessage");
|
||||
if (message) {
|
||||
showToast(message);
|
||||
sessionStorage.removeItem("welcomeMessage");
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||
});
|
||||
|
||||
// --- Auto-scroll During Drag ---
|
||||
// Adjust these values as needed:
|
||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
||||
|
||||
document.addEventListener("dragover", function (e) {
|
||||
if (e.clientY < SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, -SCROLL_SPEED);
|
||||
} else if (e.clientY > window.innerHeight - SCROLL_THRESHOLD) {
|
||||
window.scrollBy(0, SCROLL_SPEED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
167
moveFiles.php
167
moveFiles.php
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
|
||||
// --- CSRF Protection ---
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username) {
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
echo json_encode(["error" => "Read-only users are not allowed to move files."]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if (
|
||||
!$data ||
|
||||
!isset($data['source']) ||
|
||||
!isset($data['destination']) ||
|
||||
!isset($data['files'])
|
||||
) {
|
||||
echo json_encode(["error" => "Invalid request"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceFolder = trim($data['source']) ?: 'root';
|
||||
$destinationFolder = trim($data['destination']) ?: 'root';
|
||||
|
||||
// Allow only letters, numbers, underscores, dashes, spaces, and forward slashes in folder names.
|
||||
$folderPattern = '/^[A-Za-z0-9_\- \/]+$/';
|
||||
if ($sourceFolder !== 'root' && !preg_match($folderPattern, $sourceFolder)) {
|
||||
echo json_encode(["error" => "Invalid source folder name."]);
|
||||
exit;
|
||||
}
|
||||
if ($destinationFolder !== 'root' && !preg_match($folderPattern, $destinationFolder)) {
|
||||
echo json_encode(["error" => "Invalid destination folder name."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Remove any leading/trailing slashes.
|
||||
$sourceFolder = trim($sourceFolder, "/\\ ");
|
||||
$destinationFolder = trim($destinationFolder, "/\\ ");
|
||||
|
||||
// Build the source and destination directories.
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$sourceDir = ($sourceFolder === 'root')
|
||||
? $baseDir . DIRECTORY_SEPARATOR
|
||||
: $baseDir . DIRECTORY_SEPARATOR . $sourceFolder . DIRECTORY_SEPARATOR;
|
||||
$destDir = ($destinationFolder === 'root')
|
||||
? $baseDir . DIRECTORY_SEPARATOR
|
||||
: $baseDir . DIRECTORY_SEPARATOR . $destinationFolder . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Ensure destination directory exists.
|
||||
if (!is_dir($destDir)) {
|
||||
if (!mkdir($destDir, 0775, true)) {
|
||||
echo json_encode(["error" => "Could not create destination folder"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Generate the metadata file path for a given folder.
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
// Helper: Generate a unique file name if a file with the same name exists.
|
||||
function getUniqueFileName($destDir, $fileName) {
|
||||
$fullPath = $destDir . $fileName;
|
||||
clearstatcache(true, $fullPath);
|
||||
if (!file_exists($fullPath)) {
|
||||
return $fileName;
|
||||
}
|
||||
$basename = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
|
||||
$counter = 1;
|
||||
do {
|
||||
$newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : "");
|
||||
$newFullPath = $destDir . $newName;
|
||||
clearstatcache(true, $newFullPath);
|
||||
$counter++;
|
||||
} while (file_exists($destDir . $newName));
|
||||
return $newName;
|
||||
}
|
||||
|
||||
// Prepare metadata files.
|
||||
$srcMetaFile = getMetadataFilePath($sourceFolder);
|
||||
$destMetaFile = getMetadataFilePath($destinationFolder);
|
||||
|
||||
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
|
||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
||||
|
||||
$errors = [];
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
|
||||
foreach ($data['files'] as $fileName) {
|
||||
// Save the original name for metadata lookup.
|
||||
$originalName = basename(trim($fileName));
|
||||
$basename = $originalName; // Start with the original name.
|
||||
|
||||
// Validate the file name.
|
||||
if (!preg_match($safeFileNamePattern, $basename)) {
|
||||
$errors[] = "$basename has invalid characters.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$srcPath = $sourceDir . $originalName;
|
||||
$destPath = $destDir . $basename;
|
||||
|
||||
clearstatcache();
|
||||
if (!file_exists($srcPath)) {
|
||||
$errors[] = "$originalName does not exist in source.";
|
||||
continue;
|
||||
}
|
||||
|
||||
// If a file with the same name exists in destination, generate a unique name.
|
||||
if (file_exists($destPath)) {
|
||||
$uniqueName = getUniqueFileName($destDir, $basename);
|
||||
$basename = $uniqueName;
|
||||
$destPath = $destDir . $uniqueName;
|
||||
}
|
||||
|
||||
if (!rename($srcPath, $destPath)) {
|
||||
$errors[] = "Failed to move $basename";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update metadata: if there is metadata for the original file, move it under the new name.
|
||||
if (isset($srcMetadata[$originalName])) {
|
||||
$destMetadata[$basename] = $srcMetadata[$originalName];
|
||||
unset($srcMetadata[$originalName]);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT)) === false) {
|
||||
$errors[] = "Failed to update source metadata.";
|
||||
}
|
||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
||||
$errors[] = "Failed to update destination metadata.";
|
||||
}
|
||||
|
||||
if (empty($errors)) {
|
||||
echo json_encode(["success" => "Files moved successfully"]);
|
||||
} else {
|
||||
echo json_encode(["error" => implode("; ", $errors)]);
|
||||
}
|
||||
?>
|
||||
@@ -1,31 +0,0 @@
|
||||
export function sendRequest(url, method = "GET", data = null, customHeaders = {}) {
|
||||
const options = {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {}
|
||||
};
|
||||
|
||||
// Merge custom headers
|
||||
Object.assign(options.headers, customHeaders);
|
||||
|
||||
// If data is provided and is not FormData, assume JSON.
|
||||
if (data && !(data instanceof FormData)) {
|
||||
if (!options.headers["Content-Type"]) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
options.body = JSON.stringify(data);
|
||||
} else if (data instanceof FormData) {
|
||||
options.body = data;
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`HTTP error ${response.status}: ${text}`);
|
||||
});
|
||||
}
|
||||
const clonedResponse = response.clone();
|
||||
return response.json().catch(() => clonedResponse.text());
|
||||
});
|
||||
}
|
||||
3653
openapi.json.dist
Normal file
3653
openapi.json.dist
Normal file
File diff suppressed because it is too large
Load Diff
129
public/.htaccess
Normal file
129
public/.htaccess
Normal file
@@ -0,0 +1,129 @@
|
||||
# --------------------------------
|
||||
# FileRise portable .htaccess
|
||||
# --------------------------------
|
||||
Options -Indexes -Multiviews
|
||||
DirectoryIndex index.html
|
||||
|
||||
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||
AcceptPathInfo On
|
||||
|
||||
# ---------------- Security: dotfiles ----------------
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
<FilesMatch "^\..*">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Rewrites ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
RewriteRule - - [L]
|
||||
|
||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
# - allow /api.php (ReDoc/spec page)
|
||||
# - allow /webdav.php (SabreDAV front)
|
||||
RewriteCond %{REQUEST_URI} !^/api/ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
|
||||
RewriteRule \.php$ - [F,L]
|
||||
|
||||
# 3) Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# 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]
|
||||
|
||||
# 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>
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
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/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: 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>
|
||||
|
||||
# ---------------- 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 ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||
RewriteRule .* - [F]
|
||||
</IfModule>
|
||||
29
public/api.php
Normal file
29
public/api.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// public/api.php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
if (empty($_SESSION['authenticated'])) {
|
||||
header('Location: /index.html?redirect=/api.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['spec'])) {
|
||||
header('Content-Type: application/json');
|
||||
readfile(__DIR__ . '/../openapi.json.dist');
|
||||
exit;
|
||||
}
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FileRise API Docs</title>
|
||||
<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>
|
||||
<div id="redoc-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
42
public/api/addUser.php
Normal file
42
public/api/addUser.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
// public/api/addUser.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/addUser.php",
|
||||
* summary="Add a new user",
|
||||
* description="Adds a new user to the system. In setup mode, the new user is automatically made admin.",
|
||||
* operationId="addUser",
|
||||
* tags={"Users"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"username", "password"},
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="password", type="string", example="securepassword"),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="User added successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="string", example="User added successfully")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->addUser();
|
||||
28
public/api/admin/acl/getGrants.php
Normal file
28
public/api/admin/acl/getGrants.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
// public/api/admin/acl/getGrants.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = trim((string)($_GET['user'] ?? ''));
|
||||
try {
|
||||
$ctrl = new AclAdminController();
|
||||
$grants = $ctrl->getUserGrants($user);
|
||||
echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
|
||||
}
|
||||
39
public/api/admin/acl/saveGrants.php
Normal file
39
public/api/admin/acl/saveGrants.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// public/api/admin/acl/saveGrants.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
|
||||
$csrf = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||
|
||||
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$in = json_decode((string)$raw, true);
|
||||
|
||||
try {
|
||||
$ctrl = new AclAdminController();
|
||||
$res = $ctrl->saveUserGrantsPayload($in ?? []);
|
||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||
}
|
||||
41
public/api/admin/diskUsageSummary.php
Normal file
41
public/api/admin/diskUsageSummary.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// public/api/admin/diskUsageSummary.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authenticated = !empty($_SESSION['authenticated']);
|
||||
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||
|
||||
if (!$authenticated || !$isAdmin) {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Unauthorized',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optional tuning via query params
|
||||
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
|
||||
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
|
||||
|
||||
try {
|
||||
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
|
||||
http_response_code($summary['ok'] ? 200 : 404);
|
||||
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
102
public/api/admin/diskUsageTriggerScan.php
Normal file
102
public/api/admin/diskUsageTriggerScan.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
// public/api/admin/diskUsageTriggerScan.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||
|
||||
// Basic auth / admin check
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||
|
||||
if ($username === '' || !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Forbidden',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Release session lock early so the scanner/other requests aren't blocked
|
||||
@session_write_close();
|
||||
|
||||
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
|
||||
// Pro-only gate removed so free FileRise can also use the Rescan button.
|
||||
|
||||
/*
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'FileRise Pro is not active on this instance.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
try {
|
||||
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
|
||||
if (!$worker || !is_file($worker)) {
|
||||
throw new RuntimeException('disk_usage_scan.php not found.');
|
||||
}
|
||||
|
||||
// Find a PHP CLI binary that actually works (same idea as zip_worker)
|
||||
$candidates = array_values(array_filter([
|
||||
PHP_BINARY ?: null,
|
||||
'/usr/local/bin/php',
|
||||
'/usr/bin/php',
|
||||
'/bin/php',
|
||||
]));
|
||||
|
||||
$php = null;
|
||||
foreach ($candidates as $bin) {
|
||||
if (!$bin) {
|
||||
continue;
|
||||
}
|
||||
$rc = 1;
|
||||
@exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc);
|
||||
if ($rc === 0) {
|
||||
$php = $bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$php) {
|
||||
throw new RuntimeException('No working php CLI found.');
|
||||
}
|
||||
|
||||
$meta = rtrim((string)META_DIR, '/\\');
|
||||
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
|
||||
@mkdir($logDir, 0775, true);
|
||||
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
|
||||
|
||||
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
|
||||
$cmdStr =
|
||||
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
|
||||
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||
|
||||
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'pid' => $pid > 0 ? $pid : null,
|
||||
'message' => 'Disk usage scan started in the background.',
|
||||
'logFile' => $logFile,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'internal_error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
32
public/api/admin/getConfig.php
Normal file
32
public/api/admin/getConfig.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// public/api/admin/getConfig.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/admin/getConfig.php",
|
||||
* tags={"Admin"},
|
||||
* summary="Get UI configuration",
|
||||
* description="Returns a public subset for everyone; authenticated admins receive additional loginOptions fields.",
|
||||
* operationId="getAdminConfig",
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Configuration loaded",
|
||||
* @OA\JsonContent(
|
||||
* oneOf={
|
||||
* @OA\Schema(ref="#/components/schemas/AdminGetConfigPublic"),
|
||||
* @OA\Schema(ref="#/components/schemas/AdminGetConfigAdmin")
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=500, description="Server error")
|
||||
* )
|
||||
*
|
||||
* Retrieves the admin configuration settings and outputs JSON.
|
||||
* @return void
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$adminController = new AdminController();
|
||||
$adminController->getConfig();
|
||||
8
public/api/admin/installProBundle.php
Normal file
8
public/api/admin/installProBundle.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$controller = new AdminController();
|
||||
$controller->installProBundle();
|
||||
92
public/api/admin/readMetadata.php
Normal file
92
public/api/admin/readMetadata.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
// public/api/admin/readMetadata.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/admin/readMetadata.php",
|
||||
* summary="Read share metadata JSON",
|
||||
* description="Admin-only: returns the cleaned metadata for file or folder share links.",
|
||||
* tags={"Admin"},
|
||||
* operationId="readMetadata",
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\Parameter(
|
||||
* name="file",
|
||||
* in="query",
|
||||
* required=true,
|
||||
* description="Which metadata file to read",
|
||||
* @OA\Schema(type="string", enum={"share_links.json","share_folder_links.json"})
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="OK",
|
||||
* @OA\JsonContent(oneOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ShareLinksMap"),
|
||||
* @OA\Schema(ref="#/components/schemas/ShareFolderLinksMap")
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Missing or invalid file param"),
|
||||
* @OA\Response(response=403, description="Forbidden (admin only)"),
|
||||
* @OA\Response(response=500, description="Corrupted JSON")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
// Only admins may read these
|
||||
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Must supply ?file=share_links.json or share_folder_links.json
|
||||
if (empty($_GET['file'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing `file` parameter']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = basename($_GET['file']);
|
||||
$allowed = ['share_links.json', 'share_folder_links.json'];
|
||||
if (!in_array($file, $allowed, true)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid file requested']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = META_DIR . $file;
|
||||
if (!file_exists($path)) {
|
||||
// Return empty object so JS sees `{}` not an error
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode((object)[]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$jsonData = file_get_contents($path);
|
||||
$data = json_decode($jsonData, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Corrupted JSON']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ——— Clean up expired entries ———
|
||||
$now = time();
|
||||
$changed = false;
|
||||
foreach ($data as $token => $entry) {
|
||||
if (!empty($entry['expires']) && $entry['expires'] < $now) {
|
||||
unset($data[$token]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
// overwrite file with cleaned data
|
||||
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// ——— Send cleaned data back ———
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
8
public/api/admin/setLicense.php
Normal file
8
public/api/admin/setLicense.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$ctrl->setLicense();
|
||||
47
public/api/admin/updateConfig.php
Normal file
47
public/api/admin/updateConfig.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// public/api/admin/updateConfig.php
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/admin/updateConfig.php",
|
||||
* summary="Update admin configuration",
|
||||
* description="Merges the provided settings into the on-disk configuration and persists them. Requires an authenticated admin session and a valid CSRF token. When OIDC is enabled (disableOIDCLogin=false), `providerUrl`, `redirectUri`, and `clientId` are required and must be HTTPS (HTTP allowed only for localhost).",
|
||||
* operationId="updateAdminConfig",
|
||||
* tags={"Admin"},
|
||||
* security={ {{"cookieAuth": {}, "CsrfHeader": {}}} },
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(ref="#/components/schemas/AdminUpdateConfigRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Configuration updated",
|
||||
* @OA\JsonContent(ref="#/components/schemas/SimpleSuccess")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Validation error (e.g., bad authHeaderName, missing OIDC fields when enabled, or negative upload limit)",
|
||||
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="Unauthorized access or invalid CSRF token",
|
||||
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||
* // or: ref to the reusable response
|
||||
* // ref="#/components/responses/Forbidden"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=500,
|
||||
* description="Server error while loading or saving configuration",
|
||||
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$adminController = new AdminController();
|
||||
$adminController->updateConfig();
|
||||
55
public/api/auth/auth.php
Normal file
55
public/api/auth/auth.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// public/api/auth/auth.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/auth/auth.php",
|
||||
* summary="Authenticate user",
|
||||
* description="Handles user authentication via OIDC or form-based credentials. For OIDC flows, processes callbacks; otherwise, performs standard authentication with optional TOTP verification.",
|
||||
* operationId="authUser",
|
||||
* tags={"Auth"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"username", "password"},
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="password", type="string", example="secretpassword"),
|
||||
* @OA\Property(property="remember_me", type="boolean", example=true),
|
||||
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Login successful; returns user info and status",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="string", example="ok"),
|
||||
* @OA\Property(property="success", type="string", example="Login successful"),
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request (e.g., missing credentials)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized (e.g., invalid credentials, too many attempts)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=429,
|
||||
* description="Too many failed login attempts"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Handles user authentication via OIDC or form-based login.
|
||||
*
|
||||
* @return void Redirects on success or outputs JSON error.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->auth();
|
||||
37
public/api/auth/checkAuth.php
Normal file
37
public/api/auth/checkAuth.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// public/api/auth/checkAuth.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/auth/checkAuth.php",
|
||||
* summary="Check authentication status",
|
||||
* operationId="checkAuth",
|
||||
* tags={"Auth"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Authenticated status or setup flag",
|
||||
* @OA\JsonContent(
|
||||
* oneOf={
|
||||
* @OA\Schema(
|
||||
* type="object",
|
||||
* @OA\Property(property="authenticated", type="boolean", example=true),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=true),
|
||||
* @OA\Property(property="totp_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="folderOnly", type="boolean", example=false)
|
||||
* ),
|
||||
* @OA\Schema(
|
||||
* type="object",
|
||||
* @OA\Property(property="setup", type="boolean", example=true)
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->checkAuth();
|
||||
34
public/api/auth/login_basic.php
Normal file
34
public/api/auth/login_basic.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// public/api/auth/login_basic.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/auth/login_basic.php",
|
||||
* summary="Authenticate using HTTP Basic Authentication",
|
||||
* description="Performs HTTP Basic authentication. If credentials are missing, sends a 401 response prompting for Basic auth. On valid credentials, optionally handles TOTP verification and finalizes session login.",
|
||||
* operationId="loginBasic",
|
||||
* tags={"Auth"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Login successful; redirects to index.html",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="success", type="string", example="Login successful")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized due to missing credentials or invalid credentials."
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Handles HTTP Basic authentication (with optional TOTP) and logs the user in.
|
||||
*
|
||||
* @return void Redirects on success or sends a 401 header.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->loginBasic();
|
||||
30
public/api/auth/logout.php
Normal file
30
public/api/auth/logout.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/auth/logout.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/auth/logout.php",
|
||||
* summary="Logout user",
|
||||
* description="Clears the session, removes persistent login tokens, and redirects the user to the login page.",
|
||||
* operationId="logoutUser",
|
||||
* tags={"Auth"},
|
||||
* @OA\Response(
|
||||
* response=302,
|
||||
* description="Redirects to the login page with a logout flag."
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Logs the user out by clearing session data, removing persistent tokens, and destroying the session.
|
||||
*
|
||||
* @return void Redirects to index.html with a logout flag.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->logout();
|
||||
31
public/api/auth/token.php
Normal file
31
public/api/auth/token.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// public/api/auth/token.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/auth/token.php",
|
||||
* summary="Retrieve CSRF token and share URL",
|
||||
* description="Returns the current CSRF token along with the configured share URL.",
|
||||
* operationId="getToken",
|
||||
* tags={"Auth"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="CSRF token and share URL",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="csrf_token", type="string", example="0123456789abcdef..."),
|
||||
* @OA\Property(property="share_url", type="string", example="https://yourdomain.com/share.php")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Returns the CSRF token and share URL.
|
||||
*
|
||||
* @return void Outputs the JSON response.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->getToken();
|
||||
46
public/api/changePassword.php
Normal file
46
public/api/changePassword.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
// public/api/changePassword.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/changePassword.php",
|
||||
* summary="Change user password",
|
||||
* description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.",
|
||||
* operationId="changePassword",
|
||||
* tags={"Users"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"oldPassword", "newPassword", "confirmPassword"},
|
||||
* @OA\Property(property="oldPassword", type="string", example="oldpass123"),
|
||||
* @OA\Property(property="newPassword", type="string", example="newpass456"),
|
||||
* @OA\Property(property="confirmPassword", type="string", example="newpass456")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Password updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="string", example="Password updated successfully.")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="Invalid CSRF token"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->changePassword();
|
||||
38
public/api/file/copyFiles.php
Normal file
38
public/api/file/copyFiles.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// public/api/file/copyFiles.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/copyFiles.php",
|
||||
* summary="Copy files between folders",
|
||||
* description="Requires read access on source and write access on destination. Enforces folder scope and ownership.",
|
||||
* operationId="copyFiles",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="X-CSRF-Token", in="header", required=true,
|
||||
* description="CSRF token from the current session",
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"source","destination","files"},
|
||||
* @OA\Property(property="source", type="string", example="root"),
|
||||
* @OA\Property(property="destination", type="string", example="userA/projects"),
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"report.pdf","notes.txt"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Copy result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid request or folder name"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->copyFiles();
|
||||
39
public/api/file/createFile.php
Normal file
39
public/api/file/createFile.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// public/api/file/createFile.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/createFile.php",
|
||||
* summary="Create an empty file",
|
||||
* description="Requires write access on the target folder. Enforces folder-only scope.",
|
||||
* operationId="createFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","name"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="name", type="string", example="new.txt")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Creation result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
if (empty($_SESSION['authenticated'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$fc = new FileController();
|
||||
$fc->createFile();
|
||||
44
public/api/file/createShareLink.php
Normal file
44
public/api/file/createShareLink.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// public/api/file/createShareLink.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/createShareLink.php",
|
||||
* summary="Create a share link for a file",
|
||||
* description="Requires share permission on the folder. Non-admins must own the file unless bypassOwnership.",
|
||||
* operationId="createShareLink",
|
||||
* tags={"Shares"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","file"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="file", type="string", example="invoice.pdf"),
|
||||
* @OA\Property(property="expirationValue", type="integer", example=60),
|
||||
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
|
||||
* @OA\Property(property="password", type="string", example="")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Share link created",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="token", type="string", example="abc123"),
|
||||
* @OA\Property(property="url", type="string", example="/api/file/share.php?token=abc123"),
|
||||
* @OA\Property(property="expires", type="integer", example=1700000000)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->createShareLink();
|
||||
36
public/api/file/deleteFiles.php
Normal file
36
public/api/file/deleteFiles.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// public/api/file/deleteFiles.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/deleteFiles.php",
|
||||
* summary="Delete files to Trash",
|
||||
* description="Requires write access on the folder and (for non-admins) ownership of the files.",
|
||||
* operationId="deleteFiles",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="X-CSRF-Token", in="header", required=true,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","files"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"old.docx","draft.md"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Delete result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteFiles();
|
||||
27
public/api/file/deleteShareLink.php
Normal file
27
public/api/file/deleteShareLink.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/deleteShareLink.php",
|
||||
* summary="Delete a share link by token",
|
||||
* description="Deletes a share token. NOTE: Current implementation does not require authentication.",
|
||||
* operationId="deleteShareLink",
|
||||
* tags={"Shares"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"token"},
|
||||
* @OA\Property(property="token", type="string", example="abc123")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Deletion result (success or not found)")
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteShareLink();
|
||||
38
public/api/file/deleteTrashFiles.php
Normal file
38
public/api/file/deleteTrashFiles.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// public/api/file/deleteTrashFiles.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/deleteTrashFiles.php",
|
||||
* summary="Permanently delete Trash items (admin only)",
|
||||
* operationId="deleteTrashFiles",
|
||||
* tags={"Trash"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* oneOf={
|
||||
* @OA\Schema(
|
||||
* required={"deleteAll"},
|
||||
* @OA\Property(property="deleteAll", type="boolean", example=true)
|
||||
* ),
|
||||
* @OA\Schema(
|
||||
* required={"files"},
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/abc","trash/def"})
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Deletion result (model-defined)"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Admin only"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteTrashFiles();
|
||||
36
public/api/file/download.php
Normal file
36
public/api/file/download.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// public/api/file/download.php
|
||||
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/download.php",
|
||||
* summary="Download a file",
|
||||
* description="Requires view access (or own-only with ownership). Streams the file with appropriate Content-Type.",
|
||||
* operationId="downloadFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||
* @OA\Parameter(name="file", in="query", required=true, @OA\Schema(type="string"), example="photo.jpg"),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Binary file",
|
||||
* content={
|
||||
* "application/octet-stream": @OA\MediaType(
|
||||
* mediaType="application/octet-stream",
|
||||
* @OA\Schema(type="string", format="binary")
|
||||
* )
|
||||
* }
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid folder/file"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->downloadFile();
|
||||
43
public/api/file/downloadZip.php
Normal file
43
public/api/file/downloadZip.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// public/api/file/downloadZip.php
|
||||
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/downloadZip.php",
|
||||
* summary="Download multiple files as a ZIP",
|
||||
* description="Requires view access (or own-only with ownership). May be gated by account flag.",
|
||||
* operationId="downloadZip",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","files"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"a.jpg","b.png"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="ZIP archive",
|
||||
* content={
|
||||
* "application/zip": @OA\MediaType(
|
||||
* mediaType="application/zip",
|
||||
* @OA\Schema(type="string", format="binary")
|
||||
* )
|
||||
* }
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->downloadZip();
|
||||
24
public/api/file/downloadZipFile.php
Normal file
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();
|
||||
33
public/api/file/extractZip.php
Normal file
33
public/api/file/extractZip.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
// public/api/file/extractZip.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/extractZip.php",
|
||||
* summary="Extract ZIP file(s) into a folder",
|
||||
* description="Requires write access on the target folder.",
|
||||
* operationId="extractZip",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","files"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"archive.zip"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Extraction result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->extractZip();
|
||||
25
public/api/file/getFileList.php
Normal file
25
public/api/file/getFileList.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// public/api/file/getFileList.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/getFileList.php",
|
||||
* summary="List files in a folder",
|
||||
* description="Requires view access (full) or read_own (own-only results).",
|
||||
* operationId="getFileList",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||
* @OA\Response(response=200, description="Listing result (model-defined JSON)"),
|
||||
* @OA\Response(response=400, description="Invalid folder"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getFileList();
|
||||
19
public/api/file/getFileTag.php
Normal file
19
public/api/file/getFileTag.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
// public/api/file/getFileTag.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/getFileTags.php",
|
||||
* summary="Get global file tags",
|
||||
* description="Returns tag metadata (no auth in current implementation).",
|
||||
* operationId="getFileTags",
|
||||
* tags={"Tags"},
|
||||
* @OA\Response(response=200, description="Tags map (model-defined JSON)")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getFileTags();
|
||||
19
public/api/file/getShareLinks.php
Normal file
19
public/api/file/getShareLinks.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/getShareLinks.php",
|
||||
* summary="Get (raw) share links file",
|
||||
* description="Returns the full share links JSON (no auth in current implementation).",
|
||||
* operationId="getShareLinks",
|
||||
* tags={"Shares"},
|
||||
* @OA\Response(response=200, description="Share links (model-defined JSON)")
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getShareLinks();
|
||||
22
public/api/file/getTrashItems.php
Normal file
22
public/api/file/getTrashItems.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// public/api/file/getTrashItems.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/getTrashItems.php",
|
||||
* summary="List items in Trash (admin only)",
|
||||
* operationId="getTrashItems",
|
||||
* tags={"Trash"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Response(response=200, description="Trash contents (model-defined JSON)"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Admin only"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getTrashItems();
|
||||
22
public/api/file/moveFiles.php
Normal file
22
public/api/file/moveFiles.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// public/api/file/moveFiles.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/moveFiles.php",
|
||||
* operationId="moveFiles",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\RequestBody(ref="#/components/requestBodies/MoveFilesRequest"),
|
||||
* @OA\Response(response=200, description="Moved"),
|
||||
* @OA\Response(response=400, description="Bad Request"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->moveFiles();
|
||||
34
public/api/file/renameFile.php
Normal file
34
public/api/file/renameFile.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// public/api/file/renameFile.php
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/file/renameFile.php",
|
||||
* summary="Rename a file",
|
||||
* description="Requires write access; non-admins must own the file.",
|
||||
* operationId="renameFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","oldName","newName"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="oldName", type="string", example="old.pdf"),
|
||||
* @OA\Property(property="newName", type="string", example="new.pdf")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Rename result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->renameFile();
|
||||
30
public/api/file/restoreFiles.php
Normal file
30
public/api/file/restoreFiles.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/file/restoreFiles.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/restoreFiles.php",
|
||||
* summary="Restore files from Trash (admin only)",
|
||||
* operationId="restoreFiles",
|
||||
* tags={"Trash"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"files"},
|
||||
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/12345.json"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Restore result (model-defined)"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Admin only"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->restoreFiles();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user