Compare commits

...

399 Commits

Author SHA1 Message Date
e0de36e734 Add CLAUDE.md with comprehensive codebase documentation
Some checks failed
CI / php-lint (8.1) (push) Has been cancelled
CI / php-lint (8.2) (push) Has been cancelled
CI / php-lint (8.3) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / dockerfile-lint (push) Has been cancelled
CI / sanity (push) Has been cancelled
Added detailed guidance for Claude Code including:
- Project overview and tech stack
- Development setup instructions
- Architecture and directory structure
- ACL system and metadata patterns
- Common development tasks
- Code conventions and security requirements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 01:17:05 +00:00
github-actions[bot]
405ed7f925 chore(release): set APP_VERSION to v2.3.6 [skip ci] 2025-12-06 11:22:31 +00:00
Ryan
6491a7b1b3 release(v2.3.6): add non-zip multi-download, richer hover preview/peak, modified sort default 2025-12-06 06:22:20 -05:00
github-actions[bot]
3a5f5fcfd9 chore(release): set APP_VERSION to v2.3.5 [skip ci] 2025-12-06 09:02:26 +00:00
Ryan
a4efa4ff45 release(v2.3.5): make client portals ACL-aware and improve admin UX 2025-12-06 04:02:14 -05:00
Ryan
acac4235ad docs(readme): update screenshot to v2.3.4 2025-12-05 05:29:00 -05:00
github-actions[bot]
35099a5fe1 chore(release): set APP_VERSION to v2.3.4 [skip ci] 2025-12-05 10:09:53 +00:00
Ryan
bb0ac9f421 release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL 2025-12-05 05:09:42 -05:00
github-actions[bot]
b06c44a5ba chore(release): set APP_VERSION to v2.3.3 [skip ci] 2025-12-05 09:59:32 +00:00
Ryan
e58751dd83 release(v2.3.3): footer branding, Pro bundle UX + file list polish 2025-12-05 04:59:20 -05:00
Ryan
6d4881b068 chore(resources-readme): add filerise-v2.3.2.png screenshot 2025-12-03 01:31:09 -05:00
github-actions[bot]
62aacd53c4 chore(release): set APP_VERSION to v2.3.2 [skip ci] 2025-12-03 06:25:32 +00:00
Ryan
39e69882e5 release(v2.3.2): fix media preview URLs and tighten hover card layout 2025-12-03 01:25:20 -05:00
Ryan
909baed16c chore(readme): update filerise-v2.3.1.png screenshot 2025-12-03 00:34:36 -05:00
Ryan
c61bbf67f8 chore(resources): add filerise-v2.3.1.png screenshot 2025-12-03 00:33:03 -05:00
github-actions[bot]
d1ee6f11fb chore(release): set APP_VERSION to v2.3.1 [skip ci] 2025-12-03 05:29:17 +00:00
Ryan
b417217552 release(v2.3.1): polish file list actions & hover preview peak 2025-12-03 00:29:08 -05:00
github-actions[bot]
e2d1b705bd chore(release): set APP_VERSION to v2.3.0 [skip ci] 2025-12-02 07:33:14 +00:00
Ryan
4798afa89e release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export 2025-12-02 02:33:03 -05:00
Ryan
da968e51e1 docs(readme): add Discord server badge and quick link 2025-11-30 23:09:06 -05:00
Ryan
c06452600d docs(readme): link k8s deployment + Portainer templates 2025-11-30 22:10:07 -05:00
Ryan
758ad7719b docs(readme): document uploads folder best practices and existing tree usage 2025-11-30 20:59:08 -05:00
Ryan
3587f5041c docs(readme): simplify Docker quick start and document env vars 2025-11-30 06:50:19 -05:00
github-actions[bot]
da14d204a6 chore(release): set APP_VERSION to v2.2.4 [skip ci] 2025-11-30 06:59:51 +00:00
Ryan
2a87002e1f release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags 2025-11-30 01:59:42 -05:00
github-actions[bot]
4b83facc97 chore(release): set APP_VERSION to v2.2.3 [skip ci] 2025-11-30 04:26:08 +00:00
Ryan
3e473d57b4 release(v2.2.3): round gallery card corners in file grid 2025-11-29 23:25:57 -05:00
Ryan
f2ce43f18f fix(preview): harden SVG handling and normalize mime type 2025-11-29 23:11:50 -05:00
github-actions[bot]
a50fa30db2 chore(release): set APP_VERSION to v2.2.2 [skip ci] 2025-11-29 06:11:37 +00:00
Ryan
d6631adc2d release(v2.2.2): feat(folders): show inline folder stats & dates 2025-11-29 01:11:26 -05:00
github-actions[bot]
997e5067d3 chore(release): set APP_VERSION to v2.2.1 [skip ci] 2025-11-29 00:24:52 +00:00
Ryan
1c0ac50048 release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage 2025-11-28 19:24:42 -05:00
github-actions[bot]
8fc716387b chore(release): set APP_VERSION to v2.2.0 [skip ci] 2025-11-29 00:04:09 +00:00
Ryan
fe3a58924b release(v2.2.0): add storage explorer + disk usage scanner 2025-11-28 19:04:00 -05:00
github-actions[bot]
47b4cc4489 chore(release): set APP_VERSION to v2.1.0 [skip ci] 2025-11-27 07:04:40 +00:00
Ryan
3f0d1780a1 release(v2.1.0): add header zoom controls, preview tags & modal/dock polish 2025-11-27 02:04:29 -05:00
github-actions[bot]
3b62e27c7c chore(release): set APP_VERSION to v2.0.4 [skip ci] 2025-11-27 02:42:10 +00:00
Ryan
f967134631 release(v2.0.4): harden sessions and align Pro paths with USERS_DIR 2025-11-26 21:41:59 -05:00
Ryan
6b93d65d6a docs(readme): add Heise / iX press section 2025-11-26 18:36:05 -05:00
github-actions[bot]
1856325b1f chore(release): set APP_VERSION to v2.0.3 [skip ci] 2025-11-26 08:58:36 +00:00
Ryan
9e6da52691 release(v2.0.3): polish uploads, header dock, and panel fly animations 2025-11-26 03:58:25 -05:00
Ryan
959206c91c docs(readme): link install, nginx and FAQ wiki pages 2025-11-23 22:11:28 -05:00
Ryan
837deddec5 docs: add full feature wiki to README 2025-11-23 22:07:06 -05:00
Ryan
2810b97568 chore(demo): update manual sync script and lock TOTP for demo account
- Update scripts/manual-sync.sh to pull v2.0.2, backup extra demo/Pro dirs,
  and safely rsync core code without touching data, bundles, or site overrides
- After sync, automatically flip FR_DEMO_MODE to true in config/config.php
  so the droplet always runs in demo mode
- Block TOTP enable/disable/setup and recovery code generation for the
  demo account when FR_DEMO_MODE is enabled, returning 403 with clear
  JSON errors
2025-11-23 06:43:51 -05:00
github-actions[bot]
175c5f962f chore(release): set APP_VERSION to v2.0.2 [skip ci] 2025-11-23 10:58:51 +00:00
Ryan
827e65e367 release(v2.0.2): add config-driven demo mode and lock demo account changes 2025-11-23 05:58:39 -05:00
Ryan
fd8029a6bf docs: highlight Pro user groups and client portals in README 2025-11-23 04:54:35 -05:00
github-actions[bot]
de79395c3d chore(release): set APP_VERSION to v2.0.1 [skip ci] 2025-11-23 09:29:51 +00:00
Ryan
aa6f40bc24 release(v2.0.1): fix: harden portal + core login redirects for codeql 2025-11-23 04:29:41 -05:00
Ryan
abc105e087 chore(docs): readme image updated 2025-11-23 04:19:09 -05:00
github-actions[bot]
d3bcac4db0 chore(release): set APP_VERSION to v2.0.0 [skip ci] 2025-11-23 09:15:59 +00:00
Ryan
0b065111b0 release(v2.0.0): feat(pro): client portals + portal login flow 2025-11-23 04:15:49 -05:00
github-actions[bot]
3589a1c232 chore(release): set APP_VERSION to v1.9.14 [skip ci] 2025-11-21 07:12:29 +00:00
Ryan
1b4a93b060 release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish 2025-11-21 02:12:17 -05:00
github-actions[bot]
bf077b142b chore(release): set APP_VERSION to v1.9.13 [skip ci] 2025-11-20 11:44:39 +00:00
Ryan
f78e2f3f16 release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals 2025-11-20 06:44:27 -05:00
github-actions[bot]
08a84419f0 chore(release): set APP_VERSION to v1.9.12 [skip ci] 2025-11-19 07:48:18 +00:00
Ryan
49d3588322 release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL 2025-11-19 02:48:06 -05:00
github-actions[bot]
e1b20a9f1d chore(release): set APP_VERSION to v1.9.11 [skip ci] 2025-11-18 20:07:36 +00:00
Ryan
0ec8103fbf release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) 2025-11-18 15:07:27 -05:00
github-actions[bot]
3b1ebdd77f chore(release): set APP_VERSION to v1.9.10 [skip ci] 2025-11-18 07:22:03 +00:00
Ryan
3726e2423d release(v1.9.10): add Pro bundle installer and admin panel polish 2025-11-18 02:21:52 -05:00
github-actions[bot]
5613710411 chore(release): set APP_VERSION to v1.9.9 [skip ci] 2025-11-17 02:31:19 +00:00
Ryan
08f7ffccbc release(v1.9.9): fix(branding): sanitize custom logo URL 2025-11-16 21:31:08 -05:00
Ryan
ad1d41fad8 docs(readme): simplify core README and highlight Pro edition 2025-11-16 21:22:11 -05:00
github-actions[bot]
99662cd2f2 chore(release): set APP_VERSION to v1.9.8 [skip ci] 2025-11-17 02:11:15 +00:00
Ryan
060a548af4 release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks 2025-11-16 21:11:06 -05:00
Ryan
9880adb417 docs(readme): replace old video demo section with latest v1.9.7 screenshot 2025-11-14 20:29:37 -05:00
github-actions[bot]
a56641e81c chore(release): set APP_VERSION to v1.9.7 [skip ci] 2025-11-15 01:11:29 +00:00
Ryan
3b636f69d8 release(v1.9.7): harden client path guard and refine header/folder strip CSS 2025-11-14 20:11:19 -05:00
github-actions[bot]
930ed954ec chore(release): set APP_VERSION to v1.9.6 [skip ci] 2025-11-14 10:00:07 +00:00
Ryan
402f590163 release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67) 2025-11-14 04:59:58 -05:00
github-actions[bot]
ef47ad2b52 chore(release): set APP_VERSION to v1.9.5 [skip ci] 2025-11-13 10:32:48 +00:00
Ryan
8cdff954d5 release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths 2025-11-13 05:32:33 -05:00
github-actions[bot]
01cfa597b9 chore(release): set APP_VERSION to v1.9.4 [skip ci] 2025-11-13 10:06:34 +00:00
Ryan
f5e42a2e81 release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
Ryan
a031fc99c2 release(ci): harden release-on-version workflow; remove sleep/race, safer checkout, deterministic ref 2025-11-10 03:01:46 -05:00
github-actions[bot]
db73cf2876 chore(release): set APP_VERSION to v1.9.2 [skip ci] 2025-11-10 07:50:29 +00:00
Ryan
062f34dd3d release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback) 2025-11-10 02:50:19 -05:00
Ryan
63b24ba698 chore(doc): readme adjustments for webdav & security 2025-11-09 21:59:20 -05:00
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
Ryan
8f03cc7456 release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client 2025-11-08 13:53:11 -05:00
github-actions[bot]
c9a99506d7 chore(release): set APP_VERSION to v1.8.10 [skip ci] 2025-11-08 18:33:52 +00:00
Ryan
04ec0a0830 release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul 2025-11-08 13:33:38 -05:00
github-actions[bot]
429cd0314a chore(release): set APP_VERSION to v1.8.9 [skip ci] 2025-11-08 03:10:24 +00:00
Ryan
ba29cc4822 release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64) 2025-11-07 22:10:14 -05:00
github-actions[bot]
e2cd304158 chore(release): set APP_VERSION to v1.8.8 [skip ci] 2025-11-07 07:57:42 +00:00
Ryan
ca8788a694 release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60 2025-11-07 02:57:30 -05:00
Ryan
dc45fed886 chore(ci): increase release delay to 10m to avoid ref replication race 2025-11-05 00:19:56 -05:00
github-actions[bot]
a9fe342175 chore(release): set APP_VERSION to v1.8.7 [skip ci] 2025-11-05 05:02:42 +00:00
Ryan
7669f5a10b release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives 2025-11-05 00:02:32 -05:00
Ryan
34a4e06a23 chore(ci): add manual trigger + bot-derived version detection for releases 2025-11-04 23:09:31 -05:00
github-actions[bot]
d00faf5fe7 chore(release): set APP_VERSION to v1.8.6 [skip ci] 2025-11-05 03:57:04 +00:00
Ryan
ad8cbc601a release(v1.8.6): fix large ZIP downloads + safer extract; close #60 2025-11-04 22:56:53 -05:00
Ryan
40e000b5bc chore(ci): release uses correct commit for version.js + harden workflow_run 2025-11-04 22:22:24 -05:00
Ryan
eee25a4dc6 ci: revert but keep delay 2025-11-04 22:04:15 -05:00
github-actions[bot]
d66f4d93cb chore(release): set APP_VERSION to v1.8.5 [skip ci] 2025-11-05 02:17:05 +00:00
Ryan
f4f7f8ef38 release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing needs: delay, final test 2025-11-04 21:16:55 -05:00
github-actions[bot]
0ccba45c40 chore(release): set APP_VERSION to v1.8.4 [skip ci] 2025-11-05 02:09:07 +00:00
Ryan
620c916eb3 release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races 2025-11-04 21:08:58 -05:00
github-actions[bot]
f809cc09d2 chore(release): set APP_VERSION to v1.8.3 [skip ci] 2025-11-05 01:58:42 +00:00
Ryan
6758b5f73d release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust 2025-11-04 20:58:34 -05:00
github-actions[bot]
30a0aaf05e chore(release): set APP_VERSION to v1.8.2 [skip ci] 2025-11-05 01:34:51 +00:00
Ryan
c843f00738 release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37) 2025-11-04 20:34:42 -05:00
github-actions[bot]
4bb9d81370 chore(release): set APP_VERSION to v1.8.1 [skip ci] 2025-11-03 21:59:58 +00:00
Ryan
29e0497730 release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder 2025-11-03 16:59:47 -05:00
github-actions[bot]
dd3a7a5145 chore(release): set APP_VERSION to v1.8.0 [skip ci] 2025-11-03 21:40:02 +00:00
Ryan
d00db803c3 release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
2025-11-03 16:39:48 -05:00
github-actions[bot]
77a94ecd85 chore(release): set APP_VERSION to v1.7.5 [skip ci] 2025-11-02 04:56:35 +00:00
Ryan
699873848e release(v1.7.5): retrigger CI bump ensure up to date 2025-11-02 00:56:24 -04:00
Ryan
9cb12c11a6 release(v1.7.5): retrigger CI bump (no code changes) 2025-11-02 00:49:36 -04:00
Ryan
c08876380b release(v1.7.5): retrigger CI bump; chore(ci): update bump workflow 2025-11-02 00:44:29 -04:00
Ryan
5b824888cb release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:04 -04:00
Ryan
b7d7f7c3ce release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:03 -04:00
github-actions[bot]
e509b7ac9c chore(release): set APP_VERSION to v1.7.4 [skip ci] 2025-10-31 22:18:01 +00:00
Ryan
947255d94c release(v1.7.4): login hint replace toast + fix unauth boot 2025-10-31 18:17:52 -04:00
Ryan
55d44ef880 release(1.7.4): login hint replaced toast + fix unauth boot 2025-10-31 18:11:08 -04:00
github-actions[bot]
ad76e37ad5 chore(release): set APP_VERSION to v1.7.3 [skip ci] 2025-10-31 21:34:41 +00:00
Ryan
d664a2f5d8 release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth 2025-10-31 17:34:25 -04:00
github-actions[bot]
a18a8df7af chore(release): set APP_VERSION to v1.7.2 [skip ci] 2025-10-29 20:54:31 +00:00
Ryan
8cf5a34ae9 release(v1.7.2): harden asset stamping & CI verification 2025-10-29 16:54:22 -04:00
github-actions[bot]
55d5656139 chore(release): set APP_VERSION to v1.7.1 [skip ci] 2025-10-29 20:19:45 +00:00
Ryan
04be05ad1e release(v1.7.1): stamp-assets.sh invoke via bash 2025-10-29 16:19:35 -04:00
github-actions[bot]
0469d183de chore(release): set APP_VERSION to v1.7.0 [skip ci] 2025-10-29 20:07:32 +00:00
Ryan
b1de8679e0 release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish 2025-10-29 16:07:22 -04:00
github-actions[bot]
f4f7ec0dca chore(release): set APP_VERSION and stamp assets to v1.6.11 [skip ci] 2025-10-28 07:22:04 +00:00
Ryan
5a7c4704d0 release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action 2025-10-28 03:21:52 -04:00
Ryan
8b880738d6 chore(codeql): move config to repo root for default setup 2025-10-28 02:54:17 -04:00
Ryan
06c732971f ci(release): fix lint + harden release workflow 2025-10-28 02:44:13 -04:00
github-actions[bot]
ab75381acb chore(release): set APP_VERSION and stamp assets to v1.6.10 [skip ci] 2025-10-28 06:12:04 +00:00
Ryan
b1bd903072 release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow 2025-10-28 02:11:54 -04:00
Ryan
ab327acc8a chore(icons): remove material-symbols-rounded 2025-10-27 06:01:07 -04:00
Ryan
2e98ceee4c docs: move THIRD_PARTY.md to repo root 2025-10-27 05:55:05 -04:00
Ryan
3351a11927 ci(release): touch version.js to trigger release-on-version workflow 2025-10-27 05:40:36 -04:00
Ryan
4dddcf0f99 chore(ci): remove CodeQL workflow 2025-10-27 05:37:45 -04:00
Ryan
35966964e7 chore(ci,codeql): lint fixes, release trigger; stamp ?v in HTML/CSS; fix editor cache-busting 2025-10-27 05:31:01 -04:00
github-actions[bot]
7fe8e858ae chore(release): set APP_VERSION and stamp assets to v1.6.9 [skip ci] 2025-10-27 08:48:46 +00:00
Ryan
64332211c9 release(v1.6.9): feat(core) localize assets, harden headers, and speed up load 2025-10-27 04:48:31 -04:00
Ryan
3e37738e3f ci(release): touch version.js to trigger release-on-version workflow 2025-10-25 20:57:43 -04:00
Ryan
2ba33f40f8 ci(release): add workflow to auto-publish GitHub Release on version.js change 2025-10-25 20:54:49 -04:00
Ryan
badcf5c02b ci(release): add workflow to auto-publish GitHub Release on version.js updates 2025-10-25 20:51:58 -04:00
github-actions[bot]
89976f444f chore: set APP_VERSION to v1.6.8 2025-10-26 00:33:15 +00:00
Ryan
9c53c37f38 release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder 2025-10-25 20:33:01 -04:00
Ryan
a400163dfb docs(assets): folder access screenshot 2025-10-25 15:08:46 -04:00
Ryan
ebe5939bf5 docs(assets,readme): refresh screenshots and demo video for v1.6.7 2025-10-25 14:26:36 -04:00
Ryan
83757c7470 new video demo date and link in README
Updated the video demo date and link in README.
2025-10-25 13:58:25 -04:00
github-actions[bot]
8e363ea758 chore: set APP_VERSION to v1.6.7 2025-10-25 06:16:13 +00:00
Ryan
2739925f0b release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish 2025-10-25 02:16:01 -04:00
github-actions[bot]
b5610cf156 chore: set APP_VERSION to v1.6.6 2025-10-24 07:21:53 +00:00
Ryan
ae932a9aa9 release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix 2025-10-24 03:21:39 -04:00
github-actions[bot]
a106d47f77 chore: set APP_VERSION to v1.6.5 2025-10-24 06:12:09 +00:00
Ryan
41d464a4b3 release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php 2025-10-24 02:11:55 -04:00
Ryan
9e69f19e23 chore: add YAML document start markers and fix lint warnings 2025-10-24 01:51:18 -04:00
Ryan
1df7bc3f87 ci(sync-changelog): fix YAML lint error by trimming trailing spaces and ensuring EOF newline 2025-10-24 01:46:58 -04:00
github-actions[bot]
e5f9831d73 chore: set APP_VERSION to v1.6.4 2025-10-24 05:36:43 +00:00
Ryan
553bc84404 release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks 2025-10-24 01:36:30 -04:00
Ryan
88a8857a6f release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58) 2025-10-24 00:22:22 -04:00
Ryan
edefaaca36 docs(README): add additional badges 2025-10-23 03:36:27 -04:00
Ryan
ef0a8da696 chore(funding): enable GitHub Sponsors and Ko-fi links 2025-10-23 03:16:27 -04:00
Ryan
ebabb561d6 ui: fix “Change Password” button sizing in User Panel 2025-10-23 01:54:32 -04:00
Ryan
30761b6dad feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel 2025-10-23 01:43:48 -04:00
Ryan
9ef40da5aa feat(ui): unified zone toggle + polished interactions for sidebar/top cards 2025-10-23 01:06:07 -04:00
Ryan
371a763fb4 docs(README): add permissions & description table 2025-10-22 21:41:08 -04:00
Ryan
ee717af750 feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned 2025-10-22 21:36:04 -04:00
Ryan
0ad7034a7d feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads 2025-10-20 03:16:20 -04:00
Ryan
d29900d6ba security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops 2025-10-20 02:28:03 -04:00
Ryan
5ffc068041 docs(README): remove public demo badge/credentials 2025-10-20 01:18:59 -04:00
Ryan
1935cb2442 feat(dnd): default cards to sidebar on medium screens when no saved layout 2025-10-20 01:12:50 -04:00
Ryan
af9887e651 fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec 2025-10-20 00:38:35 -04:00
Ryan
327eea2835 docs(readme): reflect ACL overhaul & WebDAV enforcement 2025-10-19 07:58:38 -04:00
Ryan
3843daa228 docs: add “Security posture” to README and refresh SECURITY.md 2025-10-19 07:54:27 -04:00
Ryan
169e03be5d docs(readme): add Docker CI badge for filerise-docker workflow 2025-10-19 07:29:09 -04:00
Ryan
be605b4522 fix(config/ui): serve safe public config to non-admins; init early; gate trash UI to admins; dynamic title; demo toast (closes #56) 2025-10-19 07:19:29 -04:00
Ryan
090286164d fix(folder-model): resolve syntax error, unexpected token 2025-10-17 03:24:49 -04:00
Ryan
dc1649ace3 fix(folder-model): resolve PHP parse error 2025-10-17 03:20:33 -04:00
Ryan
b6d86b7896 chore(release): v1.5.0 - ACL hardening, Folder Access & WebDAV permissions (closes #31, closes #55) 2025-10-17 03:14:00 -04:00
Ryan
25ce6a76be feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend (closes #53) 2025-10-15 23:56:39 -04:00
Ryan
f2ab2a96bc CI: set least-privileged GITHUB_TOKEN (permissions: contents: read) 2025-10-09 00:09:27 -04:00
Ryan
c22c8e0f34 CI: fix YAML lint issues 2025-10-09 00:02:12 -04:00
Ryan
070515e7a6 CI: fix YAML lint issues and normalize headers/EOF across configs 2025-10-08 23:55:52 -04:00
Ryan
7a0f4ddbb4 ci: stabilize pipeline with PHP matrix, shellcheck, hadolint, and YAML/JSON/compose lint 2025-10-08 23:49:46 -04:00
Ryan
e1c15eb95a chore: set up CI, add compose, tighten ignores, refresh README 2025-10-08 23:43:26 -04:00
Ryan
2400dcb9eb feat(startup): stream error.log to console by default; add LOG_STREAM selector 2025-10-07 22:30:21 -04:00
Ryan
c717f8be60 feat(start.sh): stream Apache logs to console + startup polish 2025-10-07 22:14:28 -04:00
Ryan
3dd5a8664a feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48) 2025-10-06 00:10:31 -04:00
Ryan
0cb47b4054 fix(admin): OIDC optional by default; validate only when enabled (fixes #44) 2025-10-05 05:48:25 -04:00
Ryan
e3e3aaa475 chore(scanner): skip profile_pics subtree during scans 2025-10-04 03:35:39 -04:00
Ryan
494be05801 fix(scanner): rebuild per-folder metadata to match File/Folder models 2025-10-04 03:15:55 -04:00
Ryan
ceb651894e fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash 2025-10-04 03:00:15 -04:00
Ryan
ad72ef74d1 Fix: robust PUID/PGID handling; optional ownership normalization (closes #43) 2025-10-04 02:25:16 -04:00
Ryan
680c82638f Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect 2025-10-04 01:55:02 -04:00
Ryan
31f54afc74 Fix: index externally added files on startup; harden start.sh (fixes #46) 2025-10-04 01:13:45 -04:00
Ryan
4f39b3a41e Support clipboard paste image uploads with UI cleanup (closes #40) 2025-05-27 23:48:33 -04:00
Ryan
40cecc10ad support CIFS-mounted uploads and automatic scan on container start (closes #34) 2025-05-27 19:53:00 -04:00
Ryan
aee78c9750 REGEX_FOLDER_NAME updated (closes #39) 2025-05-26 18:14:08 -04:00
Ryan
16ccb66d55 Add folder-strip context menu & combined Create File/Folder dropdown 2025-05-23 08:55:09 -04:00
Ryan
9209f7a582 Center folder strip name, fix file share url, keep fileList wrapping tight (closes #38) 2025-05-22 07:32:38 -04:00
Ryan
4a736b0224 Enable drag-and-drop to folder strip & fix restore toast messaging 2025-05-21 00:54:20 -04:00
Ryan
f162a7d0d7 updateFileActionButtons to hide or show depending on action 2025-05-20 09:55:40 -04:00
Ryan
3fc526df7f Add folder strip and “Create File” functionality (closes #36) 2025-05-19 00:39:10 -04:00
Ryan
20422cf5a7 Drag‐and‐Drop Upload extended to File List 2025-05-15 02:24:26 -04:00
Ryan
492bab36ca Fix duplicated Upload & Folder cards if they were added to header and page was refreshed 2025-05-14 08:08:18 -04:00
Ryan
f2f7697994 Link updated in readme 2025-05-14 07:09:55 -04:00
Ryan
13aa011632 #nosec to silence false positive 2025-05-14 07:05:35 -04:00
Ryan
1add160f5d setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts 2025-05-14 07:00:04 -04:00
Ryan
87368143b5 Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked 2025-05-14 06:51:16 -04:00
Ryan
939aa032f0 ui: polish header and user panel with dropdown + profile pic support & file list adjustments 2025-05-14 05:20:22 -04:00
Ryan
fbd21a035b Ensure /var/www/config exists and is owned by www-data so that start.sh sed -i updates work reliably 2025-05-08 17:20:36 -04:00
Ryan
2f391d11db fix(admin-api): omit clientSecret from getConfig response for security & add OIDC scope. 2025-05-08 11:39:44 -04:00
Ryan
8c70783d5a fix(upload): relax filename validation regex to allow broader Unicode and special chars (closes #29) 2025-05-08 04:58:57 -04:00
Ryan
b4d6f01432 feat(admin): add proxy-only auth bypass and configurable auth header (closes #28) 2025-05-08 04:43:33 -04:00
Ryan
d48b15a5f4 screenshots updated 2025-05-05 08:49:27 -04:00
Ryan
d1726f0160 Refactor auth flow: add loading overlay, separate login, extract initializeApp 2025-05-05 07:28:28 -04:00
Ryan
bd1841b788 Unify modals: shared close button, truncate long filenames, fix sizing & overflow 2025-05-04 15:44:43 -04:00
Ryan
bde35d1d31 Extend clean up expired shared entries 2025-05-04 02:28:33 -04:00
Ryan
8d6a1be777 Fix FolderController readOnly create folder permission 2025-05-04 01:22:43 -04:00
Ryan
56f34ba362 Admin Panel Refactor & Enhancements 2025-05-04 00:23:46 -04:00
Ryan
4d329e046f Remove old controllers 2025-05-04 00:02:38 -04:00
Ryan
f3977153fb Refactor AdminPanel: extract module, add collapsible sections & shared-links management, enforce PascalCase controllers 2025-05-03 23:41:02 -04:00
Ryan
274bedd186 Improve PDF preview and input focus behaviors 2025-04-30 00:53:44 -04:00
Ryan
2e4dbe7f7f support custom expiration durations for file and folder shares (closes #26) 2025-04-28 20:02:11 -04:00
Ryan
0334e443eb fix(shared-folder): sanitize gallery rendering to avoid innerHTML and resolve CodeQL warning (fixes #27) 2025-04-27 18:28:39 -04:00
Ryan
76f5ed5c96 shared-folder: externalize gallery view JS & enforce CSP compliance 2025-04-27 18:18:00 -04:00
Ryan
18f588dc24 Update Manual Installation 2025-04-27 17:23:43 -04:00
Ryan
491c686762 fix: advancedSearchToggle in renderFileTable & renderGalleryView 2025-04-27 17:08:49 -04:00
Ryan
25303df677 fixed: Pagination controls & Items-per-page dropdown 2025-04-27 16:50:22 -04:00
Ryan
ae0d63b86f fix: checkbox in toolbar does not select all files (Fixes #25) 2025-04-27 15:34:41 -04:00
Ryan
41ade2e205 refactor/fix: api redoc 2025-04-26 17:31:51 -04:00
Ryan
0a9d332d60 refactor(auth): relocate logout handler to main.js 2025-04-26 04:33:01 -04:00
Ryan
1983f7705f enhance CSP for iframe and refactor gallery view event handlers 2025-04-26 04:08:56 -04:00
Ryan
6b2bf0ba70 Refactor event binding in domUtils & fileListView 2025-04-26 03:33:23 -04:00
Ryan
6d9715169c Harden security: enable CSP, add SRI, and externalize inline scripts 2025-04-26 02:28:02 -04:00
Ryan
0645a3712a Use Material icons for dark/light toggle and simplify download flows 2025-04-25 20:40:00 -04:00
Ryan
ebc32ea965 consolidate & protect API docs with php wrapper 2025-04-24 19:34:09 -04:00
Ryan
078db33458 Embed API documentation as a full-screen modal 2025-04-24 17:35:41 -04:00
Ryan
04f5cbe31f chore: update install docs, secure API docs, refine Docker vhost, remove unused folders 2025-04-24 17:02:50 -04:00
Ryan
b5a7d8d559 continue breadcrumb update 2025-04-23 23:17:23 -04:00
Ryan
58f8485b02 fix(breadcrumb): prevent XSS in title breadcrumbs – closes #24 2025-04-23 22:45:25 -04:00
Ryan
3e1da9c335 Add missing permissions in UserModel.php for TOTP login. 2025-04-23 21:15:55 -04:00
Ryan
6bf6206e1c Add missing permissions for TOTP login 2025-04-23 21:14:59 -04:00
Ryan
f9c60951c9 Removed Old CSRF logic 2025-04-23 19:53:47 -04:00
Ryan
06b3f28df0 New fetchWithCsrf with fallback for session change. start.sh session directory added. 2025-04-23 09:53:21 -04:00
Ryan
89f124250c Fixed totp isAdmin when session is missing but remember_me_token cookie present 2025-04-23 02:30:43 -04:00
Ryan
66f13fd6a7 dockerignore cleanup 2025-04-23 01:50:24 -04:00
Ryan
a81d9cb940 Enhance remember me 2025-04-23 01:47:27 -04:00
Ryan
13b8871200 docker: remove symlink add alias for uploads folder 2025-04-22 22:28:06 -04:00
Ryan
2792c05c1c docker: consolidate config & security improvements 2025-04-22 21:34:21 -04:00
Ryan
6ccfc88acb Composer & WebDAV readme changes 2025-04-22 19:27:53 -04:00
Ryan
7f1d59b33a add acknowledgements to README and LICENSE 2025-04-22 19:06:33 -04:00
Ryan
e4e8b108d2 Add permissions to workflow 2025-04-22 18:11:42 -04:00
Ryan
242661a9c9 New Admin Panel settings (enableWebDAV & shareMaxUploadSize) 2025-04-22 17:11:19 -04:00
Ryan
ca3e2f316c PUID/PGID changes 2025-04-22 08:19:10 -04:00
Ryan
6ff4aa5f34 support PUID/PGID env vars & update Unraid template 2025-04-22 08:06:29 -04:00
Ryan
1eb54b8e6e Updated WebDav and curl readme 2025-04-21 13:23:54 -04:00
Ryan
4a6c424540 Add sabre/dav to dependencies and fix resumable.js url 2025-04-21 11:57:01 -04:00
Ryan
d23d5b7f3f Added WebDAV Support & curl 2025-04-21 11:12:42 -04:00
Ryan
a48ba09f02 Add WebDAV support with user folderOnly restrictions 2025-04-21 10:39:55 -04:00
Ryan
61357af203 Fetch URL fixes, Extended “Remember Me” cookie behavior, submitLogin() overhaul 2025-04-19 17:53:01 -04:00
Ryan
e390a35e8a Gallery View add selection actions and search filtering 2025-04-18 02:58:30 -04:00
Ryan
7e50ba1f70 test pipeline 2025-04-18 00:52:39 -04:00
Ryan
cc41f8cc95 update sync 2025-04-18 00:51:51 -04:00
Ryan
7c31b9689f update changelog & test pipeline 2025-04-18 00:43:33 -04:00
Ryan
461921b7bc Remember me adjustment 2025-04-18 00:40:17 -04:00
Ryan
3b58123584 User Panel added API Docs link 2025-04-17 06:45:00 -04:00
Ryan
cd9d7eb0ba HTML wrapper that pulls in Redoc from the CDN 2025-04-17 06:28:05 -04:00
Ryan
c0c8d68dc4 mark openapi.json & api.html as documentation 2025-04-17 06:11:27 -04:00
Ryan
2dfcb4062f Generate OpenAPI spec and API HTML docs 2025-04-17 06:04:15 -04:00
Ryan
d839b3ac1c Fix folder share gallery view link 2025-04-17 02:38:32 -04:00
Ryan
766458f707 Dockerfile, custom-php.ini & start.sh moved into main repo 2025-04-17 02:10:46 -04:00
Ryan
22cce5a898 Overhaul networkUtils and expand auth 2025-04-17 01:20:18 -04:00
Ryan
75d3bf5a9b Refactor fixes and adjustments 2025-04-16 17:15:59 -04:00
Ryan
4ec4ba832f Changes 4/16 Refactor API endpoints and modularize controllers and models 2025-04-16 13:04:36 -04:00
Ryan
97b67593bc remove 2025-04-16 11:43:29 -04:00
Ryan
ec5c3fc452 Refactor API endpoints and modularize controllers and models 2025-04-16 11:40:17 -04:00
Ryan
853d8835d9 Adjust Gallery View max columns based on screen size & Adjust headerTitle to update globally 2025-04-15 16:07:20 -04:00
Ryan
1d36d002c6 Force resumable chunk size & fix chunk cleanup 2025-04-14 16:58:12 -04:00
Ryan
844976ef89 Updated zoom-in, zoom-out, rotate-left & rotate-right buttons to match prev & next 2025-04-14 13:06:30 -04:00
Ryan
66e0d7ecbe filePreview enhancements and ensure gallery view toggle displays 2025-04-14 03:42:24 -04:00
Ryan
a5fbcdef88 Fix Gallery View: medium screen devices get 3 max columns and small screen devices 2 max columns. 2025-04-14 00:29:30 -04:00
Ryan
a897d1734f Gallery view enhancements 2025-04-13 20:48:34 -04:00
Ryan
a9c4200827 Extend i18n support: Add new translation keys for Download and Share modals 2025-04-13 18:38:21 -04:00
Ryan
97559873dc Added translations and data attributes for almost all user-facing text 2025-04-13 15:22:52 -04:00
Ryan
0683b27534 New Admin section Header Settings to change Header Title. 2025-04-13 05:51:19 -04:00
Ryan
49c42e8096 RecoveryCodeModal i18n 2025-04-13 04:17:32 -04:00
Ryan
ed39e112a9 more i18n.js keys added 2025-04-13 03:55:51 -04:00
Ryan
25edab923a Decreased header size some more and clickable logo 2025-04-13 02:52:14 -04:00
Ryan
b8ae3c4402 Advanced/Basic search button as material icon on same row as search bar. 2025-04-12 16:31:05 -04:00
Ryan
fb537b1d61 Change search box text when enabling advanced search. 2025-04-12 15:36:23 -04:00
Ryan
90439022e3 Gallery View Toggle button moved to header 2025-04-12 15:16:59 -04:00
Ryan
b4c8738b8a advance search readme added 2025-04-12 14:18:14 -04:00
Ryan
e193bf9b13 Advanced Search Implementation 2025-04-12 14:16:52 -04:00
Ryan
a70d8fc2c7 v1.1.2 2025-04-12 12:52:15 -04:00
Ryan
d9f69d7917 Fuse.js Integration for Indexed Real-Time Searching & Dependencies added 2025-04-12 12:46:28 -04:00
Ryan
28ac23c2f6 Fix totp_setup.php to use header-based CSRF token verification 2025-04-11 23:40:27 -04:00
Ryan
b06c49f213 ensure consistent session behavior 2025-04-11 22:36:43 -04:00
Ryan
8553efabc1 Upgrade dependencies: update robthree/twofactorauth to v3 and endroid/qr-code to v5; update TOTP integration (namespace, enum, QR provider) accordingly 2025-04-11 18:41:44 -04:00
Ryan
81a08ffd5b fix missing 2025-04-11 10:55:33 -04:00
Ryan
296dae96a5 regex configuration constants 2025-04-11 10:44:26 -04:00
Ryan
337f529afd fix drag-drop, UI glitches, & update validation 2025-04-11 03:21:09 -04:00
Ryan
4360f2830a new filetag endpoint and config.php update 2025-04-10 10:33:13 -04:00
Ryan
894cc938a5 i18n-title for folder buttons 2025-04-10 04:27:40 -04:00
Ryan
01801ba950 fix gallery view filename wrapping 2025-04-10 03:58:26 -04:00
Ryan
5b592575a4 adjust card mouseover start position 2025-04-10 03:18:08 -04:00
Ryan
7cce03d092 Use create folder material icon and increase version 2025-04-10 02:59:04 -04:00
Ryan
ff92a6d26c Reduce header height & create folder material icon 2025-04-10 02:29:48 -04:00
Ryan
4fa5faa2bf Shift Key Multi‑Selection & Total Files and File Size 2025-04-10 00:45:35 -04:00
Ryan
98850a7c65 Update 2025-04-09 02:20:16 -04:00
Ryan
15bac15c33 update readme 2025-04-09 01:48:56 -04:00
Ryan
b2ff3efb3b Folder sharing added 2025-04-09 01:46:07 -04:00
Ryan
b9ce3f92a4 Progress modal for handleExtractZip 2025-04-08 21:04:44 -04:00
Ryan
f65b151bc3 Progress Modal on download buttons 2025-04-08 20:30:17 -04:00
Ryan
703c93db25 semi-complete internationalization 2025-04-08 18:49:30 -04:00
Ryan
d0353b137b German translation added 2025-04-08 18:48:22 -04:00
Ryan
a6c4c1d39c Start i18n Integration 2025-04-08 18:40:01 -04:00
Ryan
7aa4fe142a readme update for user permissions 2025-04-08 02:08:10 -04:00
Ryan
9f8337574a v1.0.9 2025-04-07 23:35:35 -04:00
Ryan
82eadebe3b adjust comments 2025-04-07 23:26:37 -04:00
Ryan
9701747214 fix(security): mitigate CodeQL alerts by adding SRI attributes and sanitizing DOM content 2025-04-07 23:15:06 -04:00
Ryan
6ff25ed426 more changes 2025-04-07 12:10:39 -04:00
Ryan
ecc41bfe31 user permissions 2025-04-07 12:08:58 -04:00
Ryan
94055d2c92 Update issue templates 2025-04-07 02:39:54 -04:00
Ryan
5b50400f28 more readme adjustments 2025-04-07 02:20:46 -04:00
Ryan
688a4bcf52 adjustments 2025-04-07 02:07:04 -04:00
Ryan
4bcbb08650 filerise.net emails 2025-04-07 01:51:20 -04:00
Ryan
1a044145ab New readme, security, changelog & wiki 2025-04-07 01:32:35 -04:00
Ryan
59299cdbed readme update 2025-04-06 15:06:51 -04:00
Ryan
4f74090818 fix toggleRecovery text 2025-04-06 14:46:37 -04:00
Ryan
70163d22f0 totp one time recovery code added 2025-04-06 14:35:45 -04:00
Ryan
b4445fc4d8 share new password page, totp setup focus, logout clear session cookie 2025-04-06 01:24:13 -04:00
Ryan
4022ccde84 v1.0.8 2025-04-05 23:46:21 -04:00
Ryan
8d370fd1bb totp adjustments 2025-04-05 23:42:52 -04:00
Ryan
5100e8bf3b extend TOTP to basic auth & OIDC. Fix share btn galleryview. 2025-04-05 22:22:47 -04:00
Ryan
899b04e49a Modularize fileManager.js 2025-04-05 15:14:49 -04:00
Ryan
07053a6b9a Organize app source 2025-04-05 13:23:31 -04:00
Ryan
58db1d49ac demo.filerise.net 2025-04-04 19:27:01 -04:00
Ryan
a2d678ee19 1.0.7 2025-04-04 18:18:19 -04:00
Ryan
da62e70c02 mitigate path traversal vulnerability by validating folder and file inputs 2025-04-04 18:02:21 -04:00
Ryan
f19d30f58a demo.filerise.net 2025-04-04 16:16:21 -04:00
Ryan
a8202adbec demo.filerise.net 2025-04-04 16:16:01 -04:00
Ryan
5dc58ffa42 loadCsrfTokenWithRetry 2025-04-04 02:29:27 -04:00
Ryan
f4f700ecda Chain Initialization After CSRF Token Is Loaded 2025-04-04 02:13:00 -04:00
Ryan
94178775d5 loadUserPermissions cleanup 2025-04-04 01:58:36 -04:00
Ryan
1d3f731483 fix UserPermission missing function. 2025-04-03 23:24:43 -04:00
Ryan
6926d5b065 userPermissions issue fixed 2025-04-03 22:06:49 -04:00
Ryan
46e9761cae Add click event listener to the “oidcLoginBtn” 2025-04-03 21:43:32 -04:00
Ryan
fa828f5dea new image 2025-04-03 20:13:41 -04:00
Ryan
3a86903827 v1.0.6 2025-04-03 20:05:14 -04:00
Ryan
4feef5700d video demo2 2025-04-03 20:01:31 -04:00
Ryan
41e2b5af90 New Images 2025-04-03 19:56:04 -04:00
Ryan
27f071ba6e remove testing style 2025-04-03 16:28:55 -04:00
Ryan
9020251ed5 Header Drop Zone Extension 2025-04-03 15:12:48 -04:00
Ryan
84822e699e Fixed fileDragStartHandler to work with tagFiles. 2025-04-02 16:05:29 -04:00
Ryan
3d57efba6c Allow mkv video playback if supported and custom toast opacity increased 2025-04-02 14:41:58 -04:00
Ryan
7c3ce0803a User Permissions (User Folder, Read Only, Disable Upload) & more changes 2025-04-02 04:49:36 -04:00
Ryan
119aefc209 adjust chunk merging logic 2025-03-31 05:44:19 -04:00
Ryan
52ddf8268f Implemented Video Progress Saving and Resuming 2025-03-31 05:33:52 -04:00
Ryan
8d7187d538 File Tagging and Global Tag Management added 2025-03-31 04:41:55 -04:00
Ryan
394e7ef041 animate merging chunks 2025-03-30 17:31:27 -04:00
Ryan
9c71c46c4e remove chunk folder php RecursiveDirectoryIterator 2025-03-30 16:49:00 -04:00
Ryan
d228dc10b0 Admin panel move user management to top 2025-03-30 15:08:59 -04:00
Ryan
3f1007b1b3 New User Panel with TOTP & change password. Admin Panel added Global OTPAuth URL 2025-03-30 14:44:53 -04:00
Ryan
27de0a9a48 composer dependencies for OIDC 2025-03-29 04:47:21 -04:00
Ryan
051544dc5a New Admin Panel, OIDC Integration & Card dragDrop adjustments 2025-03-29 04:33:10 -04:00
Ryan
89777584cf Merge pull request #5 from BitOlli/patch-1
Readme fixes
2025-03-28 11:59:19 -04:00
BitOlli
ed47e3c3bc Update README.md
fixed PR, typo
2025-03-28 15:30:20 +01:00
BitOlli
edd9094218 Update README.md
corrected docker-compose.yml section on readme.md to have proper YAML indentation
2025-03-28 15:27:33 +01:00
Ryan
3b0083516b Additional audio formats to GalleryView 2025-03-27 13:51:15 -04:00
Ryan
fee3b544dd basic auth 2025-03-27 13:43:51 -04:00
Ryan
99ed05d3de basic auth login 2025-03-27 13:39:47 -04:00
Ryan
32469778dc audio files playback added 2025-03-27 13:14:14 -04:00
Ryan
ecb4ac2c75 update 2025-03-27 11:30:21 -04:00
Ryan
4ae509acd2 Contributing added 2025-03-27 11:28:45 -04:00
Ryan
b1cd4b7bdc contribute md 2025-03-27 11:23:52 -04:00
Ryan
d57687adee new video demo 2025-03-27 05:16:50 -04:00
Ryan
64d41af21b Show extracted names in toast and new images 2025-03-27 05:11:40 -04:00
Ryan
a8f5a6d3bc Double root empty folder fix, side bar drag zone adjusted 2025-03-27 03:22:43 -04:00
Ryan
062cfc0dd4 FileRise changes 2025-03-26 18:55:27 -04:00
Ryan
32d25b1b69 FileRise changes 2025-03-26 18:54:54 -04:00
Ryan
56626aaa40 change to FileRise round 1 2025-03-26 18:26:57 -04:00
Ryan
0697fcb1df New Name FileRise, logo rises, DragDrop Upload & Folder cards 2025-03-26 18:23:42 -04:00
Ryan
c08c903810 Adjust context menu to stay in viewport 2025-03-25 04:23:37 -04:00
321 changed files with 61054 additions and 9423 deletions

20
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
---
github: [error311]
ko_fi: error311

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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
View File

@@ -0,0 +1,10 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/data/

3767
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

288
CLAUDE.md Normal file
View 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`

243
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,243 @@
# Contributing to FileRise
Thank you for your interest in contributing to FileRise! We appreciate your help in making this self-hosted file manager even better.
## Table of Contents
- [Getting Started](#getting-started)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Pull Requests](#pull-requests)
- [Coding Guidelines](#coding-guidelines)
- [Documentation](#documentation)
- [Questions and Support](#questions-and-support)
- [Adding New Language Translations](#adding-new-language-translations)
## Getting Started
1. **Fork the Repository**
Click the **Fork** button on the top-right of the FileRise GitHub page to create your own copy.
2. **Clone Your Fork**
```bash
git clone https://github.com/yourusername/FileRise.git
cd FileRise
```
3. **Set Up a Local Environment**
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.
## Reporting Bugs
If you discover a bug, please open an issue on GitHub and include:
- A clear and descriptive title.
- Detailed steps to reproduce the bug.
- The expected and actual behavior.
- Screenshots or error logs (if applicable).
- Environment details (PHP version, Apache version, OS, etc.).
## Suggesting Enhancements
Have an idea for a new feature or improvement? Before opening a new issue, please check if a similar suggestion already exists. If not, open an issue with:
- A clear description of the enhancement.
- Use cases or examples of how it would be beneficial.
- Any potential drawbacks or alternatives.
## Pull Requests
We welcome pull requests! To submit one, please follow these guidelines:
1. **Create a New Branch**
Always create a feature branch from master.
```bash
git checkout -b feature/your-feature-name
```
2. **Make Your Changes**
Commit your changes with clear, descriptive messages. Make sure your code follows the projects style guidelines.
3. **Write Tests**
If applicable, add tests to cover your changes to help us maintain code quality.
4. **Submit the Pull Request**
Push your branch to your fork and open a pull request against the master branch in the main repository. Provide a detailed description of your changes and why theyre needed.
## Coding Guidelines
- **Code Style:**
Follow the conventions used in the project. Consistent indentation, naming conventions, and clear code organization are key.
- **Documentation:**
Update documentation if your changes affect the usage or configuration of FileRise.
- **Commit Messages:**
Write meaningful commit messages that clearly describe the purpose of your changes.
## Documentation
If you notice any areas in the documentation that need improvement or updating, please feel free to include those changes in your pull requests. Clear documentation is essential for helping others understand and use FileRise.
## Questions and Support
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). Were 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
View 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"]

View File

@@ -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

469
README.md
View File

@@ -1,294 +1,293 @@
# MFE - Lightweight Multi File Upload Editor
# FileRise
**Video demo:**
[![GitHub stars](https://img.shields.io/github/stars/error311/FileRise?style=social)](https://github.com/error311/FileRise)
[![Docker pulls](https://img.shields.io/docker/pulls/error311/filerise-docker)](https://hub.docker.com/r/error311/filerise-docker)
[![Docker CI](https://img.shields.io/github/actions/workflow/status/error311/filerise-docker/main.yml?branch=main&label=Docker%20CI)](https://github.com/error311/filerise-docker/actions/workflows/main.yml)
[![CI](https://img.shields.io/github/actions/workflow/status/error311/FileRise/ci.yml?branch=master&label=CI)](https://github.com/error311/FileRise/actions/workflows/ci.yml)
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
[![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases)
[![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join_chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/7WN6f56X2e)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
https://github.com/user-attachments/assets/179e6940-5798-4482-9a69-696f806c37de
**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:**
![Dark Mode](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/dark-mode.png)
- 💾 **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/multi-file-upload-editor-docker/>
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
MFE - Multi File Upload Editor is a lightweight, secure, self-hosted web application for uploading, syntax highlight editing, drag & drop and managing files. Built with an Apache/PHP backend and a modern JavaScript (ES6 modules) frontend, it offers a responsive, dynamic file management interface. It serves as an alternative to solutions like FileGator TinyFileManager or ProjectSend, providing an easy-to-setup experience ideal for document management, image galleries, firmware file hosting, and more.
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.3.4.png)
> 💡 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, 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:** Easily move files by selecting them from the file list and simply 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 PHPs `password_hash()` for security.
- All state-changing endpoints include CSRF token validation.
- Change password supported for all users.
- **Persistent Login (Remember Me) with Encrypted Tokens:**
- Users can remain logged in across sessions securely.
- Persistent tokens are encrypted using AES256CBC 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 systems 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.
- 🚀 **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:**
![Light Mode](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/light-mode.png)
FileRise turns a folder on your server into a **web-based file explorer** with:
**Dark editor:**
![dark-editor](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/dark-editor.png)
- 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 preview**
![dark-preview](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/dark-preview.png)
**Restore or Delete Trash:**
![restore-delete](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/restore-delete.png)
**Login page:**
![Login](https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/login-page.png)
**iphone screenshots:**
<p align="center">
<img src="https://raw.githubusercontent.com/error311/multi-file-upload-editor/refs/heads/master/resources/dark-iphone.png" width="45%">
<img src="https://raw.githubusercontent.com/error311/multi-file-upload-editor/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/multi-file-upload-editor.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. **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:
On first launch youll be guided through creating the **initial admin user**.
```bash
mkdir -p /var/www/uploads /var/www/users /var/www/metadata
chmod -R 775 /var/www/uploads /var/www/users /var/www/metadata
```
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you dont want a recursive `chown` on every start.
4. **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.
5. **Configuration File:**
- Open `config.php` and adjust the following constants as necessary:
- `BASE_URL`: Set this to your web apps base URL.
- `UPLOAD_DIR`: Adjust the directory path for uploads.
- `TIMEZONE`: Set to your preferred timezone.
- `TOTAL_UPLOAD_SIZE`: Ensure it matches PHPs `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**
>
> Its 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
```
### Quickstart
Bring it up with:
1. **Pull the Docker Image:**
```bash
docker compose up -d
```
---
### 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 doesnt 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/multi-file-upload-editor-docker:latest
git clone https://github.com/error311/FileRise.git
```
2. **Run the Container:**
2. Create data directories and set permissions:
```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 multi-file-upload-editor \
error311/multi-file-upload-editor-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
```
3. **Using Docker Compose:**
Create a docker-compose.yml file with the following content:
```yaml
version: "3.8"
services:
web:
image: error311/multi-file-upload-editor-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:**
3. (Optional) Install PHP dependencies with Composer:
```bash
docker-compose up -d
composer install
```
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).
5. Browse to your FileRise URL and follow the **admin setup** screen.
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 PHPs `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 containers 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.
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
- **Logging & Troubleshooting:**
Check Apache logs (located in `/var/log/apache2/`) for troubleshooting any issues during deployment or operation.
---
Enjoy using the Multi File Upload Editor! For any issues or contributions, please refer to the [GitHub repository](https://github.com/error311/multi-file-upload-editor).
## 6. Community, support & contributing
- 🧵 **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
View 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 youd 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)
- Dont access other users data beyond whats necessary to demonstrate the issue
- Dont run automated scans against production installs you dont own
- Follow applicable laws and make a good-faith effort to respect data and availability
If you follow these guidelines, we wont 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
View File

@@ -0,0 +1,47 @@
# Third-Party Notices
FileRise bundles the following thirdparty 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.0licensed code: see `licenses/apache-2.0.txt`.
---

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

348
auth.js
View File

@@ -1,348 +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';
/**
* Updates the select element to reflect the stored items-per-page value.
*/
function updateItemsPerPageSelect() {
const selectElem = document.querySelector(".form-control.bottom-select");
if (selectElem) {
const stored = localStorage.getItem("itemsPerPage") || "10";
selectElem.value = stored;
}
}
/**
* Updates the UI for an authenticated user.
* This includes showing the main UI panels, attaching key listeners, updating header buttons,
* and displaying admin-only buttons if applicable.
*/
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 admin, show admin-only buttons; otherwise hide them.
if (data.isAdmin) {
const addUserBtn = document.getElementById("addUserBtn");
const removeUserBtn = document.getElementById("removeUserBtn");
if (addUserBtn) addUserBtn.style.display = "block";
if (removeUserBtn) removeUserBtn.style.display = "block";
let restoreBtn = document.getElementById("restoreFilesBtn");
if (!restoreBtn) {
restoreBtn = document.createElement("button");
restoreBtn.id = "restoreFilesBtn";
restoreBtn.classList.add("btn", "btn-warning");
// Using a material icon for restore.
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 >= 5) {
headerButtons.insertBefore(restoreBtn, headerButtons.children[5]);
} else {
headerButtons.appendChild(restoreBtn);
}
}
}
restoreBtn.style.display = "block";
} else {
const addUserBtn = document.getElementById("addUserBtn");
const removeUserBtn = document.getElementById("removeUserBtn");
if (addUserBtn) addUserBtn.style.display = "none";
if (removeUserBtn) removeUserBtn.style.display = "none";
const restoreBtn = document.getElementById("restoreFilesBtn");
if (restoreBtn) restoreBtn.style.display = "none";
}
updateItemsPerPageSelect();
}
/**
* Checks the user's authentication state and updates the UI accordingly.
* If in setup mode or not authenticated, it shows the proper UI elements.
* When authenticated, it calls updateAuthenticatedUI to handle the UI updates.
*/
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(error => {
console.error("Error checking authentication:", error);
return false;
});
}
/**
* Initializes authentication by checking the user's state and setting up event listeners.
* The UI will update automatically based on the auth state.
*/
function initAuth() {
checkAuthentication(false).catch(error => {
console.error("Error checking authentication:", error);
});
// Attach login event listener.
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) {
console.log("✅ Login successful. Reloading page.");
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(error => console.error("❌ Error logging in:", error));
});
}
// Attach logout event listener.
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(error => console.error("Logout error:", error));
});
// Add User functionality.
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();
// Re-check auth state to update the UI after adding a user.
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
})
.catch(error => console.error("Error adding user:", error));
});
document.getElementById("cancelUserBtn").addEventListener("click", function () {
closeAddUserModal();
});
// Remove User functionality.
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(error => console.error("Error removing user:", error));
});
document.getElementById("cancelRemoveUserBtn").addEventListener("click", function () {
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(error => {
console.error("Error changing password:", error);
showToast("Error changing password.");
});
});
}
window.changeItemsPerPage = function (value) {
localStorage.setItem("itemsPerPage", value);
const folder = window.currentFolder || "root";
if (typeof renderFileTable === "function") {
renderFileTable(folder);
}
};
document.addEventListener("DOMContentLoaded", function () {
updateItemsPerPageSelect();
});
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(error => console.error("Error loading user list:", error));
}
export { initAuth, checkAuthentication };

142
auth.php
View File

@@ -1,142 +0,0 @@
<?php
require 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
// --- Brute Force Protection Settings ---
$maxAttempts = 5;
$lockoutTime = 30 * 60; // 30 minutes in seconds
$attemptsFile = USERS_DIR . 'failed_logins.json'; // JSON file for tracking failed login attempts
$failedLogFile = USERS_DIR . 'failed_login.log'; // Plain text log for fail2ban
// Persistent tokens file for "Remember me"
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
// Load failed attempts data from file.
function loadFailedAttempts($file) {
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
if (is_array($data)) {
return $data;
}
}
return [];
}
// Save failed attempts data to file.
function saveFailedAttempts($file, $data) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
// Get current IP address.
$ip = $_SERVER['REMOTE_ADDR'];
$currentTime = time();
// Load failed attempts.
$failedAttempts = loadFailedAttempts($attemptsFile);
// Check if this IP is currently locked out.
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;
}
}
// --- 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;
}
// Get JSON input.
$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;
// Validate input: ensure both fields are provided.
if (!$username || !$password) {
echo json_encode(["error" => "Username and password are required"]);
exit;
}
// Validate username format.
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;
}
// Attempt to authenticate the user.
$userRole = authenticate($username, $password);
if ($userRole !== false) {
// On successful login, reset failed attempts for this IP.
if (isset($failedAttempts[$ip])) {
unset($failedAttempts[$ip]);
saveFailedAttempts($attemptsFile, $failedAttempts);
}
// Regenerate session ID to mitigate session fixation attacks.
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($userRole === "1"); // "1" indicates admin
// If "Remember me" is checked, generate a persistent login token.
if ($rememberMe) {
// Generate a secure random token.
$token = bin2hex(random_bytes(32));
$expiry = time() + (30 * 24 * 60 * 60); // 30 days
// Load existing persistent tokens.
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
$persistentTokens = json_decode($decryptedContent, true);
if (!is_array($persistentTokens)) {
$persistentTokens = [];
}
}
// Save token along with username, expiry, and admin status.
$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);
// Set the cookie. (Assuming $secure is defined in config.php.)
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
}
echo json_encode(["success" => "Login successful", "isAdmin" => $_SESSION["isAdmin"]]);
} else {
// On failed login, update failed attempts.
if (isset($failedAttempts[$ip])) {
$failedAttempts[$ip]['count']++;
$failedAttempts[$ip]['last_attempt'] = $currentTime;
} else {
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
}
saveFailedAttempts($attemptsFile, $failedAttempts);
// Log the failed attempt to the plain text log for fail2ban.
$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"]);
}
?>

View File

@@ -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."]);
}
?>

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +0,0 @@
<?php
// config.php
// Define constants first.
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');
// Set the default timezone.
date_default_timezone_set(TIMEZONE);
/**
* Encrypts data using AES-256-CBC.
*
* @param string $data The plaintext data.
* @param string $encryptionKey The secret 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 The Base64-encoded data containing IV and ciphertext.
* @param string $encryptionKey The secret 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 an environment variable (default for testing; override in production)
$encryptionKey = getenv('PERSISTENT_TOKENS_KEY') ?: 'default_please_change_this_key';
if (!$encryptionKey) {
die('Encryption key for persistent tokens is not set.');
}
// Allow an environment variable to override HTTPS detection.
$envSecure = getenv('SECURE');
if ($envSecure !== false) {
// Convert the environment variable value to a boolean.
$secure = filter_var($envSecure, FILTER_VALIDATE_BOOLEAN);
} else {
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
$cookieParams = [
'lifetime' => 7200,
'path' => '/',
'domain' => '', // Specify your domain if needed
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax'
];
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
session_start();
// Generate CSRF token if not already set.
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Auto-login via persistent token if session is not active.
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 (is_array($persistentTokens) && isset($persistentTokens[$_COOKIE['remember_me_token']])) {
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
if ($tokenData['expiry'] >= time()) {
// Token is valid; auto-authenticate the user.
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $tokenData["username"];
$_SESSION["isAdmin"] = $tokenData["isAdmin"]; // Restore admin status from the token
} else {
// Token expired; remove it and clear the cookie.
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 (this should point to where index.html is, e.g. your uploads directory)
define('BASE_URL', 'http://yourwebsite/uploads/');
// If BASE_URL is still the default placeholder, use the server's HTTP_HOST.
// Otherwise, use BASE_URL and append share.php.
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
View 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 PHPs $_SERVER key format:
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
}
define('AUTH_BYPASS', $cfgAuthBypass);
define('AUTH_HEADER', $cfgAuthHeader);
// ─────────────────────────────────────────────────────────────────────────────
// PROXY-ONLY AUTOLOGIN 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 didnt 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);
}

View File

@@ -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)]);
}
?>

View File

@@ -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.']);
}
?>

View File

@@ -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
View 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

View File

@@ -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)]);
}
?>

View File

@@ -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.']);
}
?>

View File

@@ -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
View 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

View File

@@ -1,361 +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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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|ogg)$/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|ogg)$/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>`;
}
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 previewFile(fileUrl, fileName) {
let modal = document.getElementById("filePreviewModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "filePreviewModal";
Object.assign(modal.style, {
display: "none",
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
modal.innerHTML = `
<div class="modal-content image-preview-modal-content">
<span id="closeFileModal" class="close-image-modal">&times;</span>
<h4 class="image-modal-header"></h4>
<div class="file-preview-container"></div>
</div>`;
document.body.appendChild(modal);
document.getElementById("closeFileModal").addEventListener("click", function () {
const video = modal.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
modal.style.display = "none";
});
modal.addEventListener("click", function (e) {
if (e.target === modal) {
const video = modal.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
modal.style.display = "none";
}
});
}
modal.querySelector("h4").textContent = fileName;
const container = modal.querySelector(".file-preview-container");
container.innerHTML = "";
const extension = fileName.split('.').pop().toLowerCase();
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(fileName)) {
const img = document.createElement("img");
img.src = fileUrl;
img.className = "image-modal-img";
container.appendChild(img);
} else if (extension === "pdf") {
const embed = document.createElement("embed");
const separator = fileUrl.indexOf('?') === -1 ? '?' : '&';
embed.src = fileUrl + separator + 't=' + new Date().getTime();
embed.type = "application/pdf";
embed.style.width = "80vw";
embed.style.height = "80vh";
embed.style.border = "none";
container.appendChild(embed);
} else if (/\.(mp4|webm|mov|ogg)$/i.test(fileName)) {
const video = document.createElement("video");
video.src = fileUrl;
video.controls = true;
video.className = "image-modal-img";
container.appendChild(video);
} else {
container.textContent = "Preview not available for this file type.";
}
modal.style.display = "flex";
}
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);
});
}

View File

@@ -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;
?>

View File

@@ -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;
?>

View File

@@ -1,146 +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 = [];
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
$allSuccess = true;
// ---------- 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 {
// 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]);
} else {
echo json_encode(["success" => false, "error" => implode(" ", $errors)]);
}
exit;
?>

File diff suppressed because it is too large Load Diff

View File

@@ -1,755 +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.
// For example, if window.currentFolder is "Folder1/Folder1SubFolder2",
// this will return: Root / Folder1 / Folder1SubFolder2.
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) {
html += `<ul class="folder-tree expanded">
<li class="folder-item">
<span class="folder-option" data-folder="root">(Root)</span>
</li>
</ul>`;
} else {
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();

View File

@@ -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]);
?>

View File

@@ -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);
?>

View File

@@ -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;
?>

View File

@@ -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);
?>

View File

@@ -1,397 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Multi File Upload Editor</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 -->
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2" />
</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>
<!-- 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" />
</svg>
</div>
</div>
<div class="header-title">
<h1>Multi File Upload Editor</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>
<!-- Restore Files Modal (Admin Only) -->
<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">
<i class="material-icons">person_add</i>
</button>
<button id="removeUserBtn" title="Remove User">
<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>
<div class="container-fluid">
<!-- Login Form -->
<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>
</div>
</div>
<!-- Main Operations: Upload and Folder Management -->
<div id="mainOperations">
<div class="container" style="max-width: 1400px; margin: 0 auto;">
<div class="row align-items-start" id="uploadFolderRow">
<!-- Upload Card: 50% width on medium, 58% on large -->
<div class="col-md-6 col-lg-7 d-flex">
<div id="uploadCard" class="card flex-fill" style="max-width: 900px; 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"
style="height: 100%;" novalidate>
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
<div id="uploadDropArea"
style="border:2px dashed #ccc; padding:20px; cursor:pointer; height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
<span>Drop files/folders here or click 'Choose Files'</span>
<br />
<!-- Note: Remove directory attributes so file picker only allows files -->
<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>
<!-- Folder Management Card -->
<div class="col-md-6 col-lg-5 d-flex">
<div id="folderManagementCard" class="card flex-fill"
style="max-width: 900px; width: 100%; position: relative;">
<!-- Card header with folder management title and help icon -->
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<span>Folder Navigation &amp; 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>
<!-- Folder actions (create, rename, delete) -->
<div class="folder-actions mt-3">
<button id="createFolderBtn" class="btn btn-primary">Create Folder</button>
<!-- Create Folder Modal -->
<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>
<!-- Rename Folder Modal -->
<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>
<!-- Delete Folder Modal -->
<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>
<!-- Help Tooltip: Initially hidden -->
<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>
</div>
</div>
<!-- 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>
<!-- Delete Files Modal -->
<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>
<!-- Copy Files Modal -->
<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>
<!-- Move Files Modal -->
<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>
<!-- Download Zip Modal -->
<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>
<!-- Change Password-->
<div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" style="cursor:pointer;">&times;</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>
<!-- Add User Modal -->
<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>
<!-- Remove User Modal -->
<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>
<!-- Rename File Modal -->
<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>
<!-- Custom Confirm Modal -->
<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>

View 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 2012present 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
View 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
View 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.

View File

@@ -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;
?>

155
main.js
View File

@@ -1,155 +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';
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();
// --- 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);
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);
}
});
});

View File

@@ -1 +0,0 @@
[]

View File

@@ -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)]);
}
?>

View File

@@ -1,33 +0,0 @@
export function sendRequest(url, method = "GET", data = null) {
console.log("Sending request to:", url, "with method:", method);
const options = {
method,
credentials: 'include', // include cookies in requests
headers: {}
};
// If data is provided and is not FormData, assume JSON.
if (data && !(data instanceof FormData)) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
// For FormData, don't set the Content-Type header; the browser will handle it.
options.body = data;
}
return fetch(url, options)
.then(response => {
console.log("Response status:", response.status);
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error ${response.status}: ${text}`);
});
}
// Clone the response so we can safely fall back if JSON parsing fails.
const clonedResponse = response.clone();
return response.json().catch(() => {
console.warn("Response is not JSON, returning as text");
return clonedResponse.text();
});
});
}

3653
openapi.json.dist Normal file

File diff suppressed because it is too large Load Diff

129
public/.htaccess Normal file
View 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
View 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
View 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();

View 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()]);
}

View 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()]);
}

View 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(),
]);
}

View 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(),
]);
}

View 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();

View 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();

View 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;

View 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();

View 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
View 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();

View 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();

View 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();

View 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
View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@@ -0,0 +1,34 @@
<?php
// public/api/file/saveFile.php
/**
* @OA\Put(
* path="/api/file/saveFile.php",
* summary="Create or overwrite a files 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();

View 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
View 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();

View File

@@ -0,0 +1,23 @@
<?php
// public/api/file/zipStatus.php
/**
* @OA\Get(
* path="/api/file/zipStatus.php",
* summary="Check status of a background ZIP build",
* description="Returns status for the authenticated user's token.",
* operationId="zipStatus",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Response(response=200, description="Status payload"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->zipStatus();

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$username = (string)($_SESSION['username'] ?? '');
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,38 @@
<?php
// public/api/folder/createFolder.php
/**
* @OA\Post(
* path="/api/folder/createFolder.php",
* summary="Create a new folder",
* description="Requires authentication, CSRF token, and write access to the parent folder. Seeds ACL owner.",
* operationId="createFolder",
* tags={"Folders"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(
* name="X-CSRF-Token", in="header", required=true,
* description="CSRF token from the current session",
* @OA\Schema(type="string")
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"folderName"},
* @OA\Property(property="folderName", type="string", example="reports"),
* @OA\Property(property="parent", type="string", nullable=true, example="root",
* description="Parent folder (default root)")
* )
* ),
* @OA\Response(response=200, description="Creation result (model-defined JSON)"),
* @OA\Response(response=400, description="Invalid input"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=403, description="Forbidden"),
* @OA\Response(response=405, description="Method not allowed")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController();
$folderController->createFolder();

Some files were not shown because too many files have changed in this diff Show More