Compare commits
379 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 | ||
|
|
a2d678ee19 | ||
|
|
da62e70c02 | ||
|
|
f19d30f58a | ||
|
|
a8202adbec | ||
|
|
5dc58ffa42 | ||
|
|
f4f700ecda | ||
|
|
94178775d5 | ||
|
|
1d3f731483 | ||
|
|
6926d5b065 | ||
|
|
46e9761cae | ||
|
|
fa828f5dea | ||
|
|
3a86903827 | ||
|
|
4feef5700d | ||
|
|
41e2b5af90 | ||
|
|
27f071ba6e | ||
|
|
9020251ed5 | ||
|
|
84822e699e | ||
|
|
3d57efba6c | ||
|
|
7c3ce0803a | ||
|
|
119aefc209 | ||
|
|
52ddf8268f | ||
|
|
8d7187d538 | ||
|
|
394e7ef041 | ||
|
|
9c71c46c4e | ||
|
|
d228dc10b0 | ||
|
|
3f1007b1b3 |
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
|
||||
|
||||
514
README.md
514
README.md
@@ -1,351 +1,293 @@
|
||||
# FileRise - Elevate your File Management
|
||||
# FileRise
|
||||
|
||||
**Video demo:**
|
||||
[](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)
|
||||
|
||||
https://github.com/user-attachments/assets/9546a76b-afb0-4068-875a-0eab478b514d
|
||||
**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.
|
||||
|
||||
**Dark mode:**
|
||||

|
||||
- 💾 **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.
|
||||
|
||||
changelogs available here: <https://github.com/error311/FileRise-docker/>
|
||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
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], and Rename) 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.
|
||||
|
||||
- **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, and any changes are immediately reflected in real time.
|
||||
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), and operations (copy/move/rename) update these metadata files accordingly.
|
||||
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder, streamlining navigation across subfolders. Supports drag & drop to move files.
|
||||
- **Folder Manager Context Menu:**
|
||||
- Right-clicking on a folder (in the folder tree or breadcrumb) 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) is provided to trigger folder deletion (with safeguards to prevent deleting the root folder).
|
||||
|
||||
- **Sorting & Pagination:**
|
||||
- The file list 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” navigation buttons.
|
||||
|
||||
- **Share Link Functionality:**
|
||||
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and a 1-day option) and optional password protection.
|
||||
- Share links are stored in a JSON file with details including the folder, file, expiration timestamp, and hashed password.
|
||||
- The share endpoint (`share.php`) 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.
|
||||
- Change password supported for all users.
|
||||
- Basic Auth supported 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, the tokens are decrypted on the server to re-establish user sessions without requiring 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 and customized user experience.
|
||||
|
||||
- **Dark Mode/Light Mode:**
|
||||
- The application automatically adapts to the operating system’s theme preference by default and offers a manual toggle.
|
||||
- The dark mode provides a darker background with lighter text and adjusts UI elements (including the CodeMirror editor) for optimal readability in low-light conditions.
|
||||
- The light mode maintains a bright interface for well-lit environments.
|
||||
|
||||
- **Server & Security Enhancements:**
|
||||
- The Apache configuration (or .htaccess files) is set to disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized users from viewing directory contents.
|
||||
- Direct access to sensitive files (e.g., `users.txt`) is restricted through .htaccess rules.
|
||||
- A proxy download mechanism has been implemented (via endpoints like `download.php` and `downloadZip.php`) so that every file download request goes through a PHP script. This script validates the session and CSRF token before streaming the file, ensuring that even if a file URL is guessed, only authenticated users can access it.
|
||||
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments to further protect file content.
|
||||
|
||||
- **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.
|
||||
- They can restore individual files (with conflict checks) or restore all files back to their original location.
|
||||
- **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 (permanently deletes) any files in the Trash older than three days, helping manage storage and prevent the accumulation of outdated files.
|
||||
- **User Interface:**
|
||||
- The trash modal displays details such as file name, uploader/deleter, and the trashed date/time.
|
||||
- Material icons with tooltips visually represent the restore and delete actions.
|
||||
|
||||
- **Drag & Drop Cards with Dedicated Drop Zones:**
|
||||
- **Sidebar Drop Zone:**
|
||||
- Cards (such as the upload card or folder management card) 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.
|
||||
- **Seamless Interaction:**
|
||||
- Both drop zones support smooth drag and drop interactions with animations and pointer event adjustments to prevent interference, ensuring that cards can be dropped reliably regardless of screen position.
|
||||
|
||||
### 🔒 Admin Panel & OpenID Connect (OIDC) Integration
|
||||
|
||||
- **Flexible Authentication:**
|
||||
- Supports multiple authentication methods including Form-based Login, Basic Auth, and OpenID Connect (OIDC). Allow disable of only two login options.
|
||||
|
||||
- **Secure OIDC Authentication:**
|
||||
- Integrates seamlessly with OIDC providers (e.g., Keycloak, Okta).
|
||||
- Admin-configurable OIDC settings, including Provider URL, Client ID, Client Secret, and Redirect URI.
|
||||
- All sensitive configurations are securely stored in an encrypted JSON file.
|
||||
|
||||
- **Dynamic Admin Panel:**
|
||||
- Intuitive Admin Panel with Material Icons for quick recognition and access.
|
||||
- Allows administrators to easily manage authentication settings, user management, and login methods.
|
||||
- Real-time validation prevents disabling all authentication methods simultaneously, ensuring continuous secure access.
|
||||
- 🚀 **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
|
||||
|
||||
**Light mode:**
|
||||

|
||||
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
|
||||
|
||||
**Dark mode default:**
|
||||

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

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

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

|
||||
|
||||
**Dark Login page:**
|
||||

|
||||
|
||||
**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 '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 |
610
auth.js
610
auth.js
@@ -1,610 +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';
|
||||
|
||||
// Default OIDC configuration (can be overridden via API in production)
|
||||
const currentOIDCConfig = {
|
||||
providerUrl: "https://your-oidc-provider.com",
|
||||
clientId: "YOUR_CLIENT_ID",
|
||||
clientSecret: "YOUR_CLIENT_SECRET",
|
||||
redirectUri: "https://yourdomain.com/auth.php?oidc=callback"
|
||||
};
|
||||
|
||||
function updateItemsPerPageSelect() {
|
||||
const selectElem = document.querySelector(".form-control.bottom-select");
|
||||
if (selectElem) {
|
||||
const stored = localStorage.getItem("itemsPerPage") || "10";
|
||||
selectElem.value = stored;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const disableFormLogin = localStorage.getItem("disableFormLogin") === "true";
|
||||
const disableBasicAuth = localStorage.getItem("disableBasicAuth") === "true";
|
||||
const disableOIDCLogin = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
||||
}
|
||||
|
||||
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);
|
||||
updateLoginOptionsUIFromStorage();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.setItem("disableFormLogin", "false");
|
||||
localStorage.setItem("disableBasicAuth", "false");
|
||||
localStorage.setItem("disableOIDCLogin", "false");
|
||||
updateLoginOptionsUIFromStorage();
|
||||
});
|
||||
}
|
||||
|
||||
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 (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>';
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
if (headerButtons) {
|
||||
if (headerButtons.children.length >= 3) {
|
||||
headerButtons.insertBefore(restoreBtn, headerButtons.children[3]);
|
||||
} 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");
|
||||
// Use material icon for the admin panel button.
|
||||
adminPanelBtn.innerHTML = '<i class="material-icons" title="Admin Panel">admin_panel_settings</i>';
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
if (headerButtons) {
|
||||
// Insert the adminPanelBtn immediately after the restoreBtn.
|
||||
if (restoreBtn.nextSibling) {
|
||||
headerButtons.insertBefore(adminPanelBtn, restoreBtn.nextSibling);
|
||||
} else {
|
||||
headerButtons.appendChild(adminPanelBtn);
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!");
|
||||
window.location.reload();
|
||||
} else {
|
||||
if (data.error && data.error.includes("Too many failed login attempts")) {
|
||||
showToast(data.error);
|
||||
const loginButton = 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: " + (data.error || "Unknown error"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
|
||||
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
|
||||
if (oidcLoginBtn) {
|
||||
oidcLoginBtn.addEventListener("click", function () {
|
||||
window.location.href = "auth.php?oidc";
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadOIDCConfig() {
|
||||
return fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
if (config.oidc) {
|
||||
Object.assign(currentOIDCConfig, config.oidc);
|
||||
}
|
||||
return currentOIDCConfig;
|
||||
})
|
||||
.catch(() => currentOIDCConfig);
|
||||
}
|
||||
|
||||
function openAdminPanel() {
|
||||
fetch("getConfig.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
if (config.oidc) {
|
||||
Object.assign(currentOIDCConfig, config.oidc);
|
||||
}
|
||||
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;
|
||||
`;
|
||||
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>Admin Panel</h3>
|
||||
<form id="adminPanelForm">
|
||||
<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="${currentOIDCConfig.providerUrl}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientId">OIDC Client ID:</label>
|
||||
<input type="text" id="oidcClientId" class="form-control" value="${currentOIDCConfig.clientId}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcClientSecret">OIDC Client Secret:</label>
|
||||
<input type="text" id="oidcClientSecret" class="form-control" value="${currentOIDCConfig.clientSecret}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidcRedirectUri">OIDC Redirect URI:</label>
|
||||
<input type="text" id="oidcRedirectUri" class="form-control" value="${currentOIDCConfig.redirectUri}" />
|
||||
</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>
|
||||
<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>
|
||||
</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", function (e) {
|
||||
if (e.target === adminModal) {
|
||||
closeAdminPanel();
|
||||
}
|
||||
});
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
document.getElementById("adminOpenAddUser").addEventListener("click", function () {
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
});
|
||||
document.getElementById("adminOpenRemoveUser").addEventListener("click", function () {
|
||||
loadUserList();
|
||||
toggleVisibility("removeUserModal", true);
|
||||
});
|
||||
document.getElementById("saveAdminSettings").addEventListener("click", function () {
|
||||
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");
|
||||
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;
|
||||
sendRequest("updateConfig.php", "POST", {
|
||||
oidc: newOIDCConfig,
|
||||
disableFormLogin,
|
||||
disableBasicAuth,
|
||||
disableOIDCLogin
|
||||
}, { "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);
|
||||
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 = localStorage.getItem("disableFormLogin") === "true";
|
||||
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
|
||||
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
} else {
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0, 0, 0, 0.7)" : "rgba(0, 0, 0, 0.3)";
|
||||
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 = currentOIDCConfig.providerUrl;
|
||||
document.getElementById("oidcClientId").value = currentOIDCConfig.clientId;
|
||||
document.getElementById("oidcClientSecret").value = currentOIDCConfig.clientSecret;
|
||||
document.getElementById("oidcRedirectUri").value = currentOIDCConfig.redirectUri;
|
||||
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";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
let adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) {
|
||||
document.getElementById("oidcProviderUrl").value = currentOIDCConfig.providerUrl;
|
||||
document.getElementById("oidcClientId").value = currentOIDCConfig.clientId;
|
||||
document.getElementById("oidcClientSecret").value = currentOIDCConfig.clientSecret;
|
||||
document.getElementById("oidcRedirectUri").value = currentOIDCConfig.redirectUri;
|
||||
adminModal.style.display = "flex";
|
||||
} else {
|
||||
openAdminPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeAdminPanel() {
|
||||
const adminModal = document.getElementById("adminPanelModal");
|
||||
if (adminModal) {
|
||||
adminModal.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
window.changeItemsPerPage = function (value) {
|
||||
localStorage.setItem("itemsPerPage", value);
|
||||
const folder = window.currentFolder || "root";
|
||||
if (typeof renderFileTable === "function") {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
updateItemsPerPageSelect();
|
||||
const disableFormLogin = localStorage.getItem("disableFormLogin") === "true";
|
||||
const disableBasicAuth = localStorage.getItem("disableBasicAuth") === "true";
|
||||
const disableOIDCLogin = localStorage.getItem("disableOIDCLogin") === "true";
|
||||
updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
|
||||
});
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
export { initAuth, checkAuthentication };
|
||||
183
auth.php
183
auth.php
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
require 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- 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) {
|
||||
echo json_encode(['error' => 'Failed to decrypt admin configuration.']);
|
||||
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);
|
||||
|
||||
// Since PKCE is disabled in Keycloak, we do not set any PKCE parameters.
|
||||
|
||||
if ($_GET['oidc'] === 'callback') {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
$username = $oidc->requestUserInfo('preferred_username');
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = false;
|
||||
header("Location: index.html");
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["error" => "Authentication failed: " . $e->getMessage()]);
|
||||
exit();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["error" => "Authentication initiation failed: " . $e->getMessage()]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: Form-based Authentication ---
|
||||
|
||||
$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;
|
||||
if (!file_exists($usersFile)) {
|
||||
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 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();
|
||||
}
|
||||
|
||||
$userRole = authenticate($username, $password);
|
||||
if ($userRole !== false) {
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
unset($failedAttempts[$ip]);
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($userRole === "1");
|
||||
|
||||
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" => ($userRole === "1")
|
||||
];
|
||||
$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"]]);
|
||||
} 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"]);
|
||||
}
|
||||
?>
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
// changePassword.php
|
||||
require 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Make sure the user is logged in.
|
||||
session_start();
|
||||
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) {
|
||||
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
|
||||
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.
|
||||
$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,22 +0,0 @@
|
||||
<?php
|
||||
require '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)) === '') {
|
||||
// Return JSON indicating setup mode
|
||||
echo json_encode(["setup" => true]);
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["authenticated" => false]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"authenticated" => true,
|
||||
"isAdmin" => isset($_SESSION["isAdmin"]) ? $_SESSION["isAdmin"] : false
|
||||
]);
|
||||
?>
|
||||
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
114
config.php
114
config.php
@@ -1,114 +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.');
|
||||
}
|
||||
|
||||
// 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'
|
||||
];
|
||||
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"];
|
||||
} 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);
|
||||
}
|
||||
143
copyFiles.php
143
copyFiles.php
@@ -1,143 +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;
|
||||
}
|
||||
|
||||
$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,86 +0,0 @@
|
||||
<?php
|
||||
require '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;
|
||||
}
|
||||
|
||||
// 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
|
||||
147
deleteFiles.php
147
deleteFiles.php
@@ -1,147 +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";
|
||||
$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,89 +0,0 @@
|
||||
<?php
|
||||
require '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;
|
||||
}
|
||||
|
||||
// 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,105 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
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..." 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)$/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|webm|mov)$/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>${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);
|
||||
});
|
||||
}
|
||||
59
download.php
59
download.php
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// For GET requests (which download.php will use), we assume session authentication is enough.
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Determine the directory.
|
||||
if ($folder !== 'root') {
|
||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$directory = UPLOAD_DIR;
|
||||
}
|
||||
|
||||
$filePath = $directory . $file;
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "File not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve the file.
|
||||
$mimeType = mime_content_type($filePath);
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// For images, serve inline; for other types, force download.
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) {
|
||||
header('Content-Disposition: inline; filename="' . basename($filePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($filePath));
|
||||
|
||||
// Disable caching.
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($filePath);
|
||||
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;
|
||||
?>
|
||||
364
dragAndDrop.js
364
dragAndDrop.js
@@ -1,364 +0,0 @@
|
||||
// dragAndDrop.js
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// This sets up all drag-and-drop event listeners for cards.
|
||||
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 once.
|
||||
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';
|
||||
}
|
||||
|
||||
// Use the stored initialRect rather than recalculating.
|
||||
initialLeft = initialRect.left + window.pageXOffset;
|
||||
initialTop = initialRect.top + window.pageYOffset;
|
||||
offsetX = e.pageX - initialLeft;
|
||||
offsetY = e.pageY - initialTop;
|
||||
|
||||
// Append card to body and fix its dimensions to prevent shrinking.
|
||||
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 = '';
|
||||
}
|
||||
|
||||
let droppedInSidebar = false;
|
||||
let droppedInTop = 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;
|
||||
}
|
||||
}
|
||||
// If not dropped in sidebar, 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;
|
||||
// Use computed style to determine container's width.
|
||||
const containerWidth = parseFloat(window.getComputedStyle(container).width);
|
||||
card.style.width = "363px";
|
||||
// Animate the card sliding in.
|
||||
animateVerticalSlide(card);
|
||||
// After animation completes, clear the inline width.
|
||||
setTimeout(() => {
|
||||
card.style.removeProperty('width');
|
||||
}, 210);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If dropped in neither area, return card to its original container.
|
||||
if (!droppedInSidebar && !droppedInTop) {
|
||||
const orig = document.getElementById(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
card.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear inline styles from dragging.
|
||||
[
|
||||
'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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
155
extractZip.php
155
extractZip.php
@@ -1,155 +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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
?>
|
||||
1544
fileManager.js
1544
fileManager.js
File diff suppressed because it is too large
Load Diff
747
folderManager.js
747
folderManager.js
@@ -1,747 +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 => {
|
||||
// Ensure folderPath is a string
|
||||
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
|
||||
// ----------------------
|
||||
// Render breadcrumb for a normalized folder path.
|
||||
function renderBreadcrumb(normalizedFolder) {
|
||||
if (normalizedFolder === "root") {
|
||||
return `<span class="breadcrumb-link" data-folder="root">Root</span>`;
|
||||
}
|
||||
const parts = normalizedFolder.split("/");
|
||||
let breadcrumbItems = [];
|
||||
// Always start with "Root".
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="root">Root</span>`);
|
||||
let cumulative = "";
|
||||
parts.forEach((part, index) => {
|
||||
cumulative = index === 0 ? 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('');
|
||||
}
|
||||
|
||||
// Bind click and drag-and-drop events to breadcrumb links.
|
||||
function bindBreadcrumbEvents() {
|
||||
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbLinks.forEach(link => {
|
||||
// Click event for navigation.
|
||||
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");
|
||||
if (folder === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(folder) + ")";
|
||||
}
|
||||
// Expand the folder tree to ensure the target is visible.
|
||||
expandTreePath(folder);
|
||||
// Update folder tree selection.
|
||||
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");
|
||||
}
|
||||
// Load the file list.
|
||||
loadFileList(folder);
|
||||
// Re-bind breadcrumb events to ensure all links remain active.
|
||||
bindBreadcrumbEvents();
|
||||
});
|
||||
|
||||
// Drag-and-drop events.
|
||||
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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// DOM Building Functions for Folder Tree
|
||||
// ----------------------
|
||||
|
||||
// Recursively builds HTML for the folder tree as nested <ul> elements.
|
||||
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
const state = loadFolderTreeState();
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||
for (const folder in tree) {
|
||||
// Skip the trash folder (case-insensitive)
|
||||
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;
|
||||
}
|
||||
|
||||
// Expands the folder tree along a given normalized path.
|
||||
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 {
|
||||
const response = await fetch('getFolderList.php');
|
||||
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 folders = await response.json();
|
||||
|
||||
// If returned items are objects (with a "folder" property), extract folder paths.
|
||||
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
|
||||
folders = folders.map(item => item.folder);
|
||||
}
|
||||
// Filter out duplicate "root" entries if present.
|
||||
folders = folders.filter(folder => folder !== "root");
|
||||
|
||||
if (!Array.isArray(folders)) {
|
||||
console.error("Folder list response is not an array:", folders);
|
||||
return;
|
||||
}
|
||||
|
||||
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="root">[<span class="custom-dash">-</span>]</span>
|
||||
<span class="folder-option root-folder-option" data-folder="root">(Root)</span>
|
||||
</div>`;
|
||||
if (folders.length > 0) {
|
||||
const tree = buildFolderTree(folders);
|
||||
html += renderFolderTree(tree, "", "block");
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach drag-and-drop event listeners to folder nodes.
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
});
|
||||
|
||||
// Determine current folder (normalized).
|
||||
if (selectedFolder) {
|
||||
window.currentFolder = selectedFolder;
|
||||
} else {
|
||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||
}
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
// Update file list title using breadcrumb.
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
if (window.currentFolder === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
}
|
||||
// Bind breadcrumb events (click and drag/drop).
|
||||
bindBreadcrumbEvents();
|
||||
|
||||
// Load file list.
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
// Expand tree to current folder.
|
||||
const folderState = loadFolderTreeState();
|
||||
if (window.currentFolder !== "root" && folderState[window.currentFolder] !== "none") {
|
||||
expandTreePath(window.currentFolder);
|
||||
}
|
||||
|
||||
// Highlight current folder in folder tree.
|
||||
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");
|
||||
}
|
||||
|
||||
// Event binding for folder selection in folder tree.
|
||||
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");
|
||||
if (selected === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(selected) + ")";
|
||||
}
|
||||
// Re-bind breadcrumb events so the new breadcrumb is clickable.
|
||||
bindBreadcrumbEvents();
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
|
||||
// Event binding for toggling folders.
|
||||
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["root"] = "block";
|
||||
} else {
|
||||
nestedUl.classList.remove("expanded");
|
||||
nestedUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state["root"] = "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";
|
||||
// Focus the input field after a short delay to ensure modal is visible.
|
||||
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 to display the custom context menu at (x, y) with given menu items.
|
||||
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);
|
||||
}
|
||||
|
||||
// Set styles based on dark mode.
|
||||
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";
|
||||
}
|
||||
|
||||
// Clear previous items.
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu handler for folder tree and breadcrumb items.
|
||||
function folderManagerContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get the closest folder element (either from the tree or breadcrumb).
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
|
||||
// Update current folder and highlight the selected element.
|
||||
window.currentFolder = folder;
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
|
||||
// Build context menu items.
|
||||
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);
|
||||
}
|
||||
|
||||
// Bind contextmenu events to folder tree and breadcrumb elements.
|
||||
function bindFolderManagerContextMenu() {
|
||||
// Bind context menu to folder tree container.
|
||||
const container = document.getElementById("folderTreeContainer");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
}
|
||||
|
||||
// Bind context menu to breadcrumb links.
|
||||
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbNodes.forEach(node => {
|
||||
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
node.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Hide context menu when clicking elsewhere.
|
||||
document.addEventListener("click", function () {
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("keydown", function (e) {
|
||||
// Skip if the user is typing in an input, textarea, or contentEditable element.
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On macOS, "Delete" is typically reported as "Backspace" (keyCode 8)
|
||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||
// Ensure a folder is selected and it isn't the root folder.
|
||||
if (window.currentFolder && window.currentFolder !== "root") {
|
||||
// Prevent default (avoid navigating back on macOS).
|
||||
e.preventDefault();
|
||||
// Call your existing folder delete function.
|
||||
openDeleteFolderModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Call this binding function after rendering the folder tree and breadcrumbs.
|
||||
bindFolderManagerContextMenu();
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
require '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;
|
||||
}
|
||||
echo $decryptedContent;
|
||||
} 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
|
||||
]
|
||||
]);
|
||||
}
|
||||
?>
|
||||
101
getFileList.php
101
getFileList.php
@@ -1,101 +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
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(["files" => $fileList]);
|
||||
?>
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
require '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;
|
||||
?>
|
||||
24
getUsers.php
24
getUsers.php
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
require '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) {
|
||||
// Optionally, validate username format:
|
||||
if (preg_match('/^[A-Za-z0-9_\- ]+$/', $parts[0])) {
|
||||
$users[] = ["username" => $parts[0]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode($users);
|
||||
?>
|
||||
398
index.html
398
index.html
@@ -1,398 +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">
|
||||
<button id="logoutBtn" title="Logout">
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
</button>
|
||||
<button id="changePasswordBtn" title="Change Password">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></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>
|
||||
|
||||
<!-- JavaScript Files -->
|
||||
<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.
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
require 'config.php';
|
||||
session_start();
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Reuse the same authentication function
|
||||
function authenticate($username, $password)
|
||||
{
|
||||
global $usersFile;
|
||||
if (!file_exists($usersFile)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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
|
||||
$userRole = authenticate($username, $password);
|
||||
if ($userRole !== false) {
|
||||
// Successful login
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($userRole === "1"); // Assuming "1" means admin
|
||||
|
||||
// Redirect to the main page
|
||||
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 '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;
|
||||
?>
|
||||
166
main.js
166
main.js
@@ -1,166 +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 } from './dragAndDrop.js'
|
||||
|
||||
function loadCsrfToken() {
|
||||
fetch('token.php', { credentials: 'include' })
|
||||
.then(response => 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);
|
||||
})
|
||||
.catch(error => console.error("Error loading CSRF token and share URL:", error));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadCsrfToken);
|
||||
|
||||
// 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 () {
|
||||
// Call initAuth synchronously.
|
||||
initAuth();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
window.currentFolder = "root";
|
||||
loadFileList(window.currentFolder);
|
||||
initDragAndDrop();
|
||||
loadSidebarOrder();
|
||||
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.");
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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 @@
|
||||
[]
|
||||
158
moveFiles.php
158
moveFiles.php
@@ -1,158 +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;
|
||||
}
|
||||
|
||||
$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();
|
||||
34
public/api/file/saveFile.php
Normal file
34
public/api/file/saveFile.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// public/api/file/saveFile.php
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/file/saveFile.php",
|
||||
* summary="Create or overwrite a file’s content",
|
||||
* description="Requires write access. Overwrite enforces ownership for non-admins. Certain executable extensions are denied.",
|
||||
* operationId="saveFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","fileName","content"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="fileName", type="string", example="readme.txt"),
|
||||
* @OA\Property(property="content", type="string", example="Hello world")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input or disallowed extension"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->saveFile();
|
||||
36
public/api/file/saveFileTag.php
Normal file
36
public/api/file/saveFileTag.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// public/api/file/saveFileTag.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/saveFileTag.php",
|
||||
* summary="Save tags for a file (or delete one)",
|
||||
* description="Requires write access and (for non-admins) ownership when modifying.",
|
||||
* operationId="saveFileTag",
|
||||
* tags={"Tags"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder","file"},
|
||||
* @OA\Property(property="folder", type="string", example="root"),
|
||||
* @OA\Property(property="file", type="string", example="doc.md"),
|
||||
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"work","urgent"}),
|
||||
* @OA\Property(property="deleteGlobal", type="boolean", example=false),
|
||||
* @OA\Property(property="tagToDelete", type="string", nullable=true, example=null)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||
* @OA\Response(response=400, description="Invalid input"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=500, description="Internal error")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->saveFileTag();
|
||||
34
public/api/file/share.php
Normal file
34
public/api/file/share.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// public/api/file/share.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/share.php",
|
||||
* summary="Open a shared file by token",
|
||||
* description="If the link is password-protected and no password is supplied, an HTML password form is returned. Otherwise the file is streamed.",
|
||||
* operationId="shareFile",
|
||||
* tags={"Shares"},
|
||||
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="pass", in="query", required=false, @OA\Schema(type="string")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Binary file (or HTML password form when missing password)",
|
||||
* content={
|
||||
* "application/octet-stream": @OA\MediaType(
|
||||
* mediaType="application/octet-stream",
|
||||
* @OA\Schema(type="string", format="binary")
|
||||
* ),
|
||||
* "text/html": @OA\MediaType(mediaType="text/html")
|
||||
* }
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Missing token / invalid input"),
|
||||
* @OA\Response(response=403, description="Expired or invalid password"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->shareFile();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user