]> OzVa Git service - ozva-cloud/commitdiff
First changes main
authorMax Value <greenwoodw50@gmail.com>
Sun, 23 Nov 2025 10:57:08 +0000 (10:57 +0000)
committerMax Value <greenwoodw50@gmail.com>
Sun, 23 Nov 2025 10:57:08 +0000 (10:57 +0000)
25 files changed:
.dockerignore [deleted file]
.gitignore
CHANGELOG.md [deleted file]
Cargo.lock
Cargo.toml
Dockerfile [deleted file]
Dockerfile-release [deleted file]
LICENSE-APACHE [deleted file]
README.html [new file with mode: 0644]
README.md [deleted file]
SECURITY.md [deleted file]
assets/index.css
assets/index.html
assets/index.js [deleted file]
backup.js [new file with mode: 0644]
build.rs [new file with mode: 0644]
run-dev.sh [new file with mode: 0755]
src/js/actions.js [new file with mode: 0644]
src/js/const.js [new file with mode: 0644]
src/js/dragdrop.js [new file with mode: 0644]
src/js/helper.js [new file with mode: 0644]
src/js/index.js [new file with mode: 0644]
src/js/setup.js [new file with mode: 0644]
src/js/upload.js [new file with mode: 0644]
src/server.rs

diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644 (file)
index 7e8981e..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-# Directories
-/.git/
-/.github/
-/target/
-/examples/
-/docs/
-/benches/
-/tmp/
-
-# Files
-.gitignore
-*.md
-LICENSE*
\ No newline at end of file
index 0f84cc9cde3c2893321a41a3158e6272fe63fbe2..161a45125c4ca056b7fe8d55d7a0a6a838781e65 100644 (file)
@@ -1,2 +1,4 @@
 /target
-/.vscode
\ No newline at end of file
+/.vscode
+Cargo.lock
+*.min.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644 (file)
index 225f1ae..0000000
+++ /dev/null
@@ -1,666 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-## [0.43.0] - 2024-11-04
-
-### Bug Fixes
-
-- Auth failed if password contains `:` ([#449](https://github.com/sigoden/dufs/issues/449))
-- Resolve speed bottleneck in 10G network ([#451](https://github.com/sigoden/dufs/issues/451))
-
-### Features
-
-- Webui displays subdirectory items ([#457](https://github.com/sigoden/dufs/issues/457))
-- Support binding abstract unix socket ([#468](https://github.com/sigoden/dufs/issues/468))
-- Provide healthcheck API ([#474](https://github.com/sigoden/dufs/issues/474))
-
-### Refactor
-
-- Do not show size for Dir ([#447](https://github.com/sigoden/dufs/issues/447))
-
-## [0.42.0] - 2024-09-01
-
-### Bug Fixes
-
-- Garbled characters caused by atob ([#422](https://github.com/sigoden/dufs/issues/422))
-- Webui unexpected save-btn when file is non-editable ([#429](https://github.com/sigoden/dufs/issues/429))
-- Login succeeded but popup `Forbidden` ([#437](https://github.com/sigoden/dufs/issues/437))
-
-### Features
-
-- Implements remaining http cache conditionalss ([#407](https://github.com/sigoden/dufs/issues/407))
-- Base64 index-data to avoid misencoding ([#421](https://github.com/sigoden/dufs/issues/421))
-- Webui support logout ([#439](https://github.com/sigoden/dufs/issues/439))
-
-### Refactor
-
-- No inline scripts in HTML ([#391](https://github.com/sigoden/dufs/issues/391))
-- Return 400 for propfind request when depth is neither 0 nor 1 ([#403](https://github.com/sigoden/dufs/issues/403))
-- Remove sabredav-partialupdate from DAV res header ([#415](https://github.com/sigoden/dufs/issues/415))
-- Date formatting in cache tests ([#428](https://github.com/sigoden/dufs/issues/428))
-- Some query params work as flag and must not accept a value ([#431](https://github.com/sigoden/dufs/issues/431))
-- Improve logout at asserts/index.js ([#440](https://github.com/sigoden/dufs/issues/440))
-- Make logout works on safari ([#442](https://github.com/sigoden/dufs/issues/442))
-
-## [0.41.0] - 2024-05-22
-
-### Bug Fixes
-
-- Timestamp format of getlastmodified in dav xml ([#366](https://github.com/sigoden/dufs/issues/366))
-- Strange issue that occurs only on Microsoft WebDAV ([#382](https://github.com/sigoden/dufs/issues/382))
-- Head div overlap main contents when wrap ([#386](https://github.com/sigoden/dufs/issues/386))
-
-### Features
-
-- Tls handshake timeout ([#368](https://github.com/sigoden/dufs/issues/368))
-- Add api to get the hash of a file ([#375](https://github.com/sigoden/dufs/issues/375))
-- Add log-file option ([#383](https://github.com/sigoden/dufs/issues/383))
-
-### Refactor
-
-- Digest_auth related tests ([#372](https://github.com/sigoden/dufs/issues/372))
-- Add fixed-width numerals to date and size on file list page ([#378](https://github.com/sigoden/dufs/issues/378))
-
-## [0.40.0] - 2024-02-13
-
-### Bug Fixes
-
-- Guard req and destination path ([#359](https://github.com/sigoden/dufs/issues/359))
-
-### Features
-
-- Revert supporting for forbidden permission ([#352](https://github.com/sigoden/dufs/issues/352))
-
-### Refactor
-
-- Do not try to bind ipv6 if no ipv6 ([#348](https://github.com/sigoden/dufs/issues/348))
-- Improve invalid auth ([#356](https://github.com/sigoden/dufs/issues/356))
-- Improve resolve_path and handle_assets, abandon guard_path ([#360](https://github.com/sigoden/dufs/issues/360))
-
-## [0.39.0] - 2024-01-11
-
-### Bug Fixes
-
-- Upload more than 100 files in directory ([#317](https://github.com/sigoden/dufs/issues/317))
-- Auth precedence ([#325](https://github.com/sigoden/dufs/issues/325))
-- Serve files with names containing newline char ([#328](https://github.com/sigoden/dufs/issues/328))
-- Corrupted zip when downloading large folders ([#337](https://github.com/sigoden/dufs/issues/337))
-
-### Features
-
-- Empty search `?q=` list all paths ([#311](https://github.com/sigoden/dufs/issues/311))
-- Add `--compress` option ([#319](https://github.com/sigoden/dufs/issues/319))
-- Upgrade to hyper 1.0 ([#321](https://github.com/sigoden/dufs/issues/321))
-- Auth supports forbidden permissions ([#329](https://github.com/sigoden/dufs/issues/329))
-- Supports resumable uploads ([#343](https://github.com/sigoden/dufs/issues/343))
-
-### Refactor
-
-- Change the format of www-authenticate ([#312](https://github.com/sigoden/dufs/issues/312))
-- Change the value name of `--config` ([#313](https://github.com/sigoden/dufs/issues/313))
-- Optimize http range parsing and handling ([#323](https://github.com/sigoden/dufs/issues/323))
-- Propfind with auth no need to list all ([#344](https://github.com/sigoden/dufs/issues/344))
-
-## [0.38.0] - 2023-11-28
-
-### Bug Fixes
-
-- Unable to start if config file omit bind/port fields ([#294](https://github.com/sigoden/dufs/issues/294))
-
-### Features
-
-- Password can contain `:` `@` `|` ([#297](https://github.com/sigoden/dufs/issues/297))
-- Deprecate the use of `|` to separate auth rules ([#298](https://github.com/sigoden/dufs/issues/298))
-- More flexible config values ([#299](https://github.com/sigoden/dufs/issues/299))
-- Ui supports view file ([#301](https://github.com/sigoden/dufs/issues/301))
-
-### Refactor
-
-- Take improvements from the edge browser ([#289](https://github.com/sigoden/dufs/issues/289))
-- Ui change the cursor for upload-btn to a pointer ([#291](https://github.com/sigoden/dufs/issues/291))
-- Ui improve uploading progress ([#296](https://github.com/sigoden/dufs/issues/296))
-
-## [0.37.1] - 2023-11-08
-
-### Bug Fixes
-
-- Use DUFS_CONFIG to specify the config file path ([#286](https://github.com/sigoden/dufs/issues/286)
-
-## [0.37.0] - 2023-11-08
-
-### Bug Fixes
-
-- Sort path ignore case ([#264](https://github.com/sigoden/dufs/issues/264))
-- Ui show user-name next to the user-icon ([#278](https://github.com/sigoden/dufs/issues/278))
-- Auto delete half-uploaded files ([#280](https://github.com/sigoden/dufs/issues/280))
-
-### Features
-
-- Deprecate `--auth-method`,  as both options are available ([#279](https://github.com/sigoden/dufs/issues/279))
-- Support config file with `--config` option ([#281](https://github.com/sigoden/dufs/issues/281))
-- Support hashed password ([#283](https://github.com/sigoden/dufs/issues/283))
-
-### Refactor
-
-- Remove one clone on `assets_prefix` ([#270](https://github.com/sigoden/dufs/issues/270))
-- Optimize tests
-- Improve code quality ([#282](https://github.com/sigoden/dufs/issues/282))
-
-## [0.36.0] - 2023-08-24
-
-### Bug Fixes
-
-- Ui readonly if no write perm ([#258](https://github.com/sigoden/dufs/issues/258))
-
-### Testing
-
-- Remove dependency on native tls ([#255](https://github.com/sigoden/dufs/issues/255))
-
-## [0.35.0] - 2023-08-14
-
-### Bug Fixes
-
-- Search should ignore entry path ([#235](https://github.com/sigoden/dufs/issues/235))
-- Typo __ASSERTS_PREFIX__ ([#252](https://github.com/sigoden/dufs/issues/252))
-
-### Features
-
-- Sort by type first, then sort by name/mtime/size ([#241](https://github.com/sigoden/dufs/issues/241))
-
-## [0.34.2] - 2023-06-05
-
-### Bug Fixes
-
-- Ui refresh page after login ([#230](https://github.com/sigoden/dufs/issues/230))
-- Webdav only see public folder even logging in ([#231](https://github.com/sigoden/dufs/issues/231))
-
-## [0.34.1] - 2023-06-02
-
-### Bug Fixes
-
-- Auth logic ([#224](https://github.com/sigoden/dufs/issues/224))
-- Allow all cors headers and methods ([#225](https://github.com/sigoden/dufs/issues/225))
-
-### Refactor
-
-- Ui checkAuth ([#226](https://github.com/sigoden/dufs/issues/226))
-
-## [0.34.0] - 2023-06-01
-
-### Bug Fixes
-
-- URL-encoded filename when downloading in safari ([#203](https://github.com/sigoden/dufs/issues/203))
-- Ui path table show move action ([#219](https://github.com/sigoden/dufs/issues/219))
-- Ui set default max uploading to 1 ([#220](https://github.com/sigoden/dufs/issues/220))
-
-### Features
-
-- Webui editing support multiple encodings ([#197](https://github.com/sigoden/dufs/issues/197))
-- Add timestamp metadata to generated zip file ([#204](https://github.com/sigoden/dufs/issues/204))
-- Show precise file size with decimal ([#210](https://github.com/sigoden/dufs/issues/210))
-- [**breaking**] New auth ([#218](https://github.com/sigoden/dufs/issues/218))
-
-### Refactor
-
-- Cli positional rename root => SERVE_PATH([#215](https://github.com/sigoden/dufs/issues/215))
-
-## [0.33.0] - 2023-03-17
-
-### Bug Fixes
-
-- Cors allow-request-header add content-type ([#184](https://github.com/sigoden/dufs/issues/184))
-- Hidden don't works on some files ([#188](https://github.com/sigoden/dufs/issues/188))
-- Basic auth sometimes does not work ([#194](https://github.com/sigoden/dufs/issues/194))
-
-### Features
-
-- Guess plain text encoding then set content-type charset ([#186](https://github.com/sigoden/dufs/issues/186))
-
-### Refactor
-
-- Improve error handle ([#195](https://github.com/sigoden/dufs/issues/195))
-
-## [0.32.0] - 2023-02-22
-
-### Bug Fixes
-
-- Set the STOPSIGNAL to SIGINT for Dockerfile
-- Remove Method::Options auth check ([#168](https://github.com/sigoden/dufs/issues/168))
-- Clear search input also clear query ([#178](https://github.com/sigoden/dufs/issues/178))
-
-### Features
-
-- [**breaking**] Add option --allow-archive ([#152](https://github.com/sigoden/dufs/issues/152))
-- Use env var for args ([#170](https://github.com/sigoden/dufs/issues/170))
-- Hiding only directories instead of files ([#175](https://github.com/sigoden/dufs/issues/175))
-- API to search and list directories ([#177](https://github.com/sigoden/dufs/issues/177))
-- Support edit files ([#179](https://github.com/sigoden/dufs/issues/179))
-- Support new file ([#180](https://github.com/sigoden/dufs/issues/180))
-- Ui improves the login experience ([#182](https://github.com/sigoden/dufs/issues/182))
-
-## [0.31.0] - 2022-11-11
-
-### Bug Fixes
-
-- Auth not works with --path-prefix ([#138](https://github.com/sigoden/dufs/issues/138))
-- Don't search on empty query string ([#140](https://github.com/sigoden/dufs/issues/140))
-- Status code for MKCOL on existing resource ([#142](https://github.com/sigoden/dufs/issues/142))
-- Panic on PROPFIND // ([#144](https://github.com/sigoden/dufs/issues/144))
-
-### Features
-
-- Support unix sockets ([#145](https://github.com/sigoden/dufs/issues/145))
-
-## [0.30.0] - 2022-09-09
-
-### Bug Fixes
-
-- Hide path by ext name ([#126](https://github.com/sigoden/dufs/issues/126))
-
-### Features
-
-- Support sort by name, mtime, size ([#128](https://github.com/sigoden/dufs/issues/128))
-- Add --assets options to override assets ([#134](https://github.com/sigoden/dufs/issues/134))
-
-## [0.29.0] - 2022-08-03
-
-### Bug Fixes
-
-- Table row hover highlighting in dark mode ([#122](https://github.com/sigoden/dufs/issues/122))
-
-### Features
-
-- Support ecdsa tls cert ([#119](https://github.com/sigoden/dufs/issues/119))
-
-## [0.28.0] - 2022-08-01
-
-### Bug Fixes
-
-- File path contains special characters ([#114](https://github.com/sigoden/dufs/issues/114))
-
-### Features
-
-- Add table row hover ([#115](https://github.com/sigoden/dufs/issues/115))
-- Support customize http log format ([#116](https://github.com/sigoden/dufs/issues/116))
-
-## [0.27.0] - 2022-07-25
-
-### Features
-
-- Improve hidden to support glob ([#108](https://github.com/sigoden/dufs/issues/108))
-- Adjust digest auth timeout to 1day ([#110](https://github.com/sigoden/dufs/issues/110))
-
-## [0.26.0] - 2022-07-11
-
-### Bug Fixes
-
-- Cors headers ([#100](https://github.com/sigoden/dufs/issues/100))
-
-### Features
-
-- Make --path-prefix works on serving single file ([#102](https://github.com/sigoden/dufs/issues/102))
-
-## [0.25.0] - 2022-07-06
-
-### Features
-
-- Ui supports creating folder ([#91](https://github.com/sigoden/dufs/issues/91))
-- Ui supports move folder/file to new path ([#92](https://github.com/sigoden/dufs/issues/92))
-- Check permission on move/copy destination ([#93](https://github.com/sigoden/dufs/issues/93))
-- Add completions ([#97](https://github.com/sigoden/dufs/issues/97))
-- Limit the number of concurrent uploads ([#98](https://github.com/sigoden/dufs/issues/98))
-
-## [0.24.0] - 2022-07-02
-
-### Bug Fixes
-
-- Unexpected stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87))
-
-### Features
-
-- Allow search with --render-try-index ([#88](https://github.com/sigoden/dufs/issues/88))
-
-## [0.23.1] - 2022-06-30
-
-### Bug Fixes
-
-- Safari layout and compatibility ([#83](https://github.com/sigoden/dufs/issues/83))
-- Permissions of unzipped files ([#84](https://github.com/sigoden/dufs/issues/84))
-
-## [0.23.0] - 2022-06-29
-
-### Features
-
-- Use feature to conditional support tls ([#77](https://github.com/sigoden/dufs/issues/77))
-
-### Ci
-
-- Support more platforms ([#76](https://github.com/sigoden/dufs/issues/76))
-
-## [0.22.0] - 2022-06-26
-
-### Features
-
-- Support hiding folders with --hidden ([#73](https://github.com/sigoden/dufs/issues/73))
-
-## [0.21.0] - 2022-06-23
-
-### Bug Fixes
-
-- Escape name contains html escape code ([#65](https://github.com/sigoden/dufs/issues/65))
-
-### Features
-
-- Use custom logger with timestamp in rfc3339 ([#67](https://github.com/sigoden/dufs/issues/67))
-
-### Refactor
-
-- Split css/js from index.html ([#68](https://github.com/sigoden/dufs/issues/68))
-
-## [0.20.0] - 2022-06-20
-
-### Bug Fixes
-
-- DecodeURI searching string ([#61](https://github.com/sigoden/dufs/issues/61))
-
-### Features
-
-- Added basic auth ([#60](https://github.com/sigoden/dufs/issues/60))
-- Add option --allow-search ([#62](https://github.com/sigoden/dufs/issues/62))
-
-## [0.19.0] - 2022-06-19
-
-### Features
-
-- [**breaking**] Path level access control ([#52](https://github.com/sigoden/dufs/issues/52))
-- Serve single file ([#54](https://github.com/sigoden/dufs/issues/54))
-- Ui hidden root dirname ([#58](https://github.com/sigoden/dufs/issues/58))
-- Reactive webpage ([#51](https://github.com/sigoden/dufs/issues/51))
-- [**breaking**] Rename to dufs ([#59](https://github.com/sigoden/dufs/issues/59))
-
-### Refactor
-
-- [**breaking**] Rename --cors to --enable-cors ([#57](https://github.com/sigoden/dufs/issues/57))
-
-## [0.18.0] - 2022-06-18
-
-### Features
-
-- Add option --render-try-index ([#47](https://github.com/sigoden/dufs/issues/47))
-- Add slash to end of dir href
-
-## [0.17.1] - 2022-06-16
-
-### Bug Fixes
-
-- Range request ([#44](https://github.com/sigoden/dufs/issues/44))
-
-## [0.17.0] - 2022-06-15
-
-### Bug Fixes
-
-- Webdav propfind dir with slash ([#42](https://github.com/sigoden/dufs/issues/42))
-
-### Features
-
-- Listen both ipv4 and ipv6 by default ([#40](https://github.com/sigoden/dufs/issues/40))
-
-### Refactor
-
-- Trivial changes ([#41](https://github.com/sigoden/dufs/issues/41))
-
-## [0.16.0] - 2022-06-12
-
-### Features
-
-- Implement head method ([#33](https://github.com/sigoden/dufs/issues/33))
-- Display upload speed and time left ([#34](https://github.com/sigoden/dufs/issues/34))
-- Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/dufs/issues/35))
-- Options method return status 200
-
-### Testing
-
-- Add integration tests ([#36](https://github.com/sigoden/dufs/issues/36))
-
-## [0.15.1] - 2022-06-11
-
-### Bug Fixes
-
-- Cannot upload ([#32](https://github.com/sigoden/dufs/issues/32))
-
-## [0.15.0] - 2022-06-10
-
-### Bug Fixes
-
-- Encode webdav href as uri ([#28](https://github.com/sigoden/dufs/issues/28))
-- Query dir param
-
-### Features
-
-- Add basic dark theme ([#29](https://github.com/sigoden/dufs/issues/29))
-- Add empty state placeholder to page([#30](https://github.com/sigoden/dufs/issues/30))
-
-## [0.14.0] - 2022-06-07
-
-### Bug Fixes
-
-- Send index page with content-type ([#26](https://github.com/sigoden/dufs/issues/26))
-
-### Features
-
-- Support ipv6 ([#25](https://github.com/sigoden/dufs/issues/25))
-- Add favicon ([#27](https://github.com/sigoden/dufs/issues/27))
-
-## [0.13.2] - 2022-06-06
-
-### Bug Fixes
-
-- Filename xml escaping
-- Escape path-prefix/url-prefix different
-
-## [0.13.1] - 2022-06-05
-
-### Bug Fixes
-
-- Escape filename ([#21](https://github.com/sigoden/dufs/issues/21))
-
-### Refactor
-
-- Use logger ([#22](https://github.com/sigoden/dufs/issues/22))
-
-## [0.13.0] - 2022-06-05
-
-### Bug Fixes
-
-- Ctrl+c not exit sometimes
-
-### Features
-
-- Implement more webdav methods ([#13](https://github.com/sigoden/dufs/issues/13))
-- Use digest auth ([#14](https://github.com/sigoden/dufs/issues/14))
-- Add webdav proppatch handler ([#18](https://github.com/sigoden/dufs/issues/18))
-
-## [0.12.1] - 2022-06-04
-
-### Features
-
-- Support webdav ([#10](https://github.com/sigoden/dufs/issues/10))
-- Remove unzip uploaded feature ([#11](https://github.com/sigoden/dufs/issues/11))
-
-## [0.11.0] - 2022-06-03
-
-### Features
-
-- Support gracefully shutdown server
-- Listen 0.0.0.0 by default
-
-## [0.10.1] - 2022-06-02
-
-### Bug Fixes
-
-- Panic when bind already used port
-
-## [0.10.0] - 2022-06-02
-
-### Bug Fixes
-
-- Remove unzip file even failed to unzip
-- Rename --no-auth-read to --no-auth-access
-- Broken ui
-
-### Documentation
-
-- Refactor readme
-
-### Features
-
-- Change auth logic/options
-- Improve ui
-
-### Refactor
-
-- Small improvement
-
-## [0.9.0] - 2022-06-02
-
-### Documentation
-
-- Improve readme
-
-### Features
-
-- Support path prefix
-- List all ifaces when listening 0.0.0.0
-- Support tls
-
-## [0.8.0] - 2022-06-01
-
-### Bug Fixes
-
-- Some typos
-- Caught 500 if no permission to access dir
-
-### Features
-
-- Cli add allow-symlink option
-- Add some headers to res
-- Support render-index/render-spa
-
-## [0.7.0] - 2022-05-31
-
-### Bug Fixes
-
-- Downloaded zip file has no.zip ext in firefox
-- Unzip override existed file in uploadonly mode
-- Miss file 500
-- Not found dir when allow_upload is false
-
-### Features
-
-- Drag and drop uploads, upload folder
-
-## [0.6.0] - 2022-05-31
-
-### Features
-
-- Delete confirm
-- Distinct upload and delete operation
-- Support range requests
-
-### Refactor
-
-- Improve code quality
-
-## [0.5.0] - 2022-05-30
-
-### Features
-
-- Add mime and cache headers to response
-- Add no-auth-read options
-- Unzip zip file when unload
-
-## [0.4.0] - 2022-05-29
-
-### Features
-
-- Replace --static option to --no-edit
-- Add cors
-
-## [0.3.0] - 2022-05-29
-
-### Documentation
-
-- Update readme demo png
-
-### Features
-
-- Automatically create dir while uploading
-- Support searching
-
-### Refactor
-
-- Handler zip
-
-### Styling
-
-- Optimize css
-
-## [0.2.1] - 2022-05-28
-
-### Bug Fixes
-
-- Cannot upload in root
-- Optimize download zip
-
-### Documentation
-
-- Improve readme
-
-### Features
-
-- Aware RUST_LOG
-
-## [0.2.0] - 2022-05-28
-
-### Documentation
-
-- Update demo png
-- Improve readme
-
-### Features
-
-- Add logger
-- Download folder as zip file
-
-## [0.1.0] - 2022-05-26
-
-### Bug Fixes
-
-- Caught server error when symlink broken
-
-### Documentation
-
-- Improve readme
-- Update readme
-
-### Features
-
-- Add basic auth and readonly mode
-- Support delete operation
-- Remove parent path
-
-### Styling
-
-- Cargo fmt
-- Update index page
-
-### Build
-
-- Remove dev deps
-
-### Ci
-
-- Init ci
-
-<!-- generated by git-cliff -->
index 81a6319c9602e8cd52a8bb9952bdfb3e917be5cd..71f9298b5eadafa0d1bb790d43a687904d0c73f3 100644 (file)
@@ -492,63 +492,6 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
 
-[[package]]
-name = "dufs"
-version = "0.43.0"
-dependencies = [
- "alphanumeric-sort",
- "anyhow",
- "assert_cmd",
- "assert_fs",
- "async-stream",
- "async_zip",
- "base64 0.22.1",
- "bytes",
- "chardetng",
- "chrono",
- "clap",
- "clap_complete",
- "content_inspector",
- "digest_auth",
- "form_urlencoded",
- "futures-util",
- "glob",
- "headers",
- "http-body-util",
- "hyper",
- "hyper-util",
- "if-addrs",
- "indexmap",
- "lazy_static",
- "log",
- "md5",
- "mime_guess",
- "percent-encoding",
- "pin-project-lite",
- "port_check",
- "predicates",
- "regex",
- "reqwest",
- "rstest",
- "rustls-pemfile",
- "rustls-pki-types",
- "serde",
- "serde_json",
- "serde_yaml",
- "sha-crypt",
- "sha2",
- "smart-default",
- "socket2",
- "tokio",
- "tokio-rustls",
- "tokio-util",
- "url",
- "urlencoding",
- "uuid",
- "walkdir",
- "xml-rs",
-]
-
 [[package]]
 name = "encoding_rs"
 version = "0.8.35"
@@ -1303,6 +1246,63 @@ version = "1.21.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
 
+[[package]]
+name = "ozva-cloud"
+version = "0.1.0"
+dependencies = [
+ "alphanumeric-sort",
+ "anyhow",
+ "assert_cmd",
+ "assert_fs",
+ "async-stream",
+ "async_zip",
+ "base64 0.22.1",
+ "bytes",
+ "chardetng",
+ "chrono",
+ "clap",
+ "clap_complete",
+ "content_inspector",
+ "digest_auth",
+ "form_urlencoded",
+ "futures-util",
+ "glob",
+ "headers",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "if-addrs",
+ "indexmap",
+ "lazy_static",
+ "log",
+ "md5",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "port_check",
+ "predicates",
+ "regex",
+ "reqwest",
+ "rstest",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "sha-crypt",
+ "sha2",
+ "smart-default",
+ "socket2",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "url",
+ "urlencoding",
+ "uuid",
+ "walkdir",
+ "xml-rs",
+]
+
 [[package]]
 name = "parking"
 version = "2.2.1"
index 51a1e1d1db946fed30dc26bde504ba631a1163bd..2662f93b72313715df45b8582f3f4e6aa8470ff4 100644 (file)
@@ -1,12 +1,12 @@
 [package]
-name = "dufs"
-version = "0.43.0"
+name = "ozva-cloud"
+version = "0.1.0"
 edition = "2021"
-authors = ["sigoden <sigoden@gmail.com>"]
-description = "Dufs is a distinctive utility file server"
-license = "MIT OR Apache-2.0"
-homepage = "https://github.com/sigoden/dufs"
-repository = "https://github.com/sigoden/dufs"
+authors = ["William Greenwood <greenwoodbilly250@gmail.com>"]
+description = "Ozone-Value Holdings file sharing service"
+license = "MIT"
+homepage = "https://www.ozva.co.uk"
+repository = "https://git.ozva.co.uk/ozva-cloud"
 categories = ["command-line-utilities", "web-programming::http-server"]
 keywords = ["static", "file", "server", "webdav", "cli"]
 
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644 (file)
index d27bbb8..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM --platform=linux/amd64 messense/rust-musl-cross:x86_64-musl AS amd64
-COPY . .
-RUN cargo install --path . --root /
-
-FROM --platform=linux/amd64 messense/rust-musl-cross:aarch64-musl AS arm64
-COPY . .
-RUN cargo install --path . --root /
-
-FROM ${TARGETARCH} AS builder
-
-FROM scratch
-COPY --from=builder /bin/dufs /bin/dufs
-STOPSIGNAL SIGINT
-ENTRYPOINT ["/bin/dufs"]
diff --git a/Dockerfile-release b/Dockerfile-release
deleted file mode 100644 (file)
index 94219fb..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-FROM alpine as builder
-ARG REPO VER TARGETPLATFORM
-RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ 
-        TARGET="x86_64-unknown-linux-musl"; \
-    elif [  "$TARGETPLATFORM" = "linux/arm64" ]; then \
-        TARGET="aarch64-unknown-linux-musl"; \
-    elif [  "$TARGETPLATFORM" = "linux/386" ]; then \
-        TARGET="i686-unknown-linux-musl"; \
-    elif [  "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
-        TARGET="armv7-unknown-linux-musleabihf"; \
-    fi && \
-    wget https://github.com/${REPO}/releases/download/${VER}/dufs-${VER}-${TARGET}.tar.gz && \
-    tar -xf dufs-${VER}-${TARGET}.tar.gz && \
-    mv dufs /bin/
-
-FROM scratch
-COPY --from=builder /bin/dufs /bin/dufs
-STOPSIGNAL SIGINT
-ENTRYPOINT ["/bin/dufs"]
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
deleted file mode 100644 (file)
index 261eeb9..0000000
+++ /dev/null
@@ -1,201 +0,0 @@
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
-
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-   1. Definitions.
-
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   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.
diff --git a/README.html b/README.html
new file mode 100644 (file)
index 0000000..59ceb7b
--- /dev/null
@@ -0,0 +1,269 @@
+<h1>OzVa Cloud</h1>
+<p>Forked from <a href="https://github.com/sigoden/dufs">Dufs</a> at <a href="https://github.com/sigoden/dufs/commit/f8b69f4df8ee4eb8ca7ac94beb8d46e3644374aa">this commit.</a></p>
+
+<pre><code>
+Usage: ozva-cloud [OPTIONS] [serve-path]
+
+Arguments:
+  [serve-path]  Specific path to serve [default: .]
+
+Options:
+  -c, --config &lt;file&gt;        Specify configuration file
+  -b, --bind &lt;addrs&gt;         Specify bind address or unix socket
+  -p, --port &lt;port&gt;          Specify port to listen on [default: 5000]
+      --path-prefix &lt;path&gt;   Specify a path prefix
+      --hidden &lt;value&gt;       Hide paths from directory listings, e.g. tmp,*.log,*.lock
+  -a, --auth &lt;rules&gt;         Add auth roles, e.g. user:pass@/dir1:rw,/dir2
+  -A, --allow-all            Allow all operations
+      --allow-upload         Allow upload files/folders
+      --allow-delete         Allow delete files/folders
+      --allow-search         Allow search files/folders
+      --allow-symlink        Allow symlink to files/folders outside root directory
+      --allow-archive        Allow download folders as archive file
+      --enable-cors          Enable CORS, sets `Access-Control-Allow-Origin: *`
+      --render-index         Serve index.html when requesting a directory, returns 404 if not found index.html
+      --render-try-index     Serve index.html when requesting a directory, returns directory listing if not found index.html
+      --render-spa           Serve SPA(Single Page Application)
+      --assets &lt;path&gt;        Set the path to the assets directory for overriding the built-in assets
+      --log-format &lt;format&gt;  Customize http log format
+      --log-file &lt;file&gt;      Specify the file to save logs to, other than stdout/stderr
+      --compress &lt;level&gt;     Set zip compress level [default: low] [possible values: none, low, medium, high]
+      --completions &lt;shell&gt;  Print shell completion script for &lt;shell&gt; [possible values: bash, elvish, fish, powershell, zsh]
+      --tls-cert &lt;path&gt;      Path to an SSL/TLS certificate to serve with HTTPS
+      --tls-key &lt;path&gt;       Path to the SSL/TLS certificate's private key
+  -h, --help                 Print help
+  -V, --version              Print version
+</code></pre>
+<h2>Examples</h2>
+<p>Serve current working directory in read-only mode</p>
+<pre><code>ozva-cloud
+</code></pre>
+<p>Allow all operations like upload/delete/search/create/edit...</p>
+<pre><code>ozva-cloud -A
+</code></pre>
+<p>Only allow upload operation</p>
+<pre><code>ozva-cloud --allow-upload
+</code></pre>
+<p>Serve a specific directory</p>
+<pre><code>ozva-cloud Downloads
+</code></pre>
+<p>Serve a single file</p>
+<pre><code>ozva-cloud linux-distro.iso
+</code></pre>
+<p>Serve a single-page application like react/vue</p>
+<pre><code>ozva-cloud --render-spa
+</code></pre>
+<p>Serve a static website with index.html</p>
+<pre><code>ozva-cloud --render-index
+</code></pre>
+<p>Require username/password</p>
+<pre><code>ozva-cloud -a admin:123@/:rw
+</code></pre>
+<p>Listen on specific host:ip</p>
+<pre><code>ozva-cloud -b 127.0.0.1 -p 80
+</code></pre>
+<p>Listen on unix socket</p>
+<pre><code>ozva-cloud -b /tmp/ozva-cloud.socket
+</code></pre>
+<p>Use https</p>
+<pre><code>ozva-cloud --tls-cert my.crt --tls-key my.key
+</code></pre>
+<h2>API</h2>
+<p>Upload a file</p>
+<pre><code class="language-sh">curl -T path-to-file http://127.0.0.1:5000/new-path/path-to-file
+</code></pre>
+<p>Download a file</p>
+<pre><code class="language-sh">curl http://127.0.0.1:5000/path-to-file           # download the file
+curl http://127.0.0.1:5000/path-to-file?hash      # retrieve the sha256 hash of the file
+</code></pre>
+<p>Download a folder as zip file</p>
+<pre><code class="language-sh">curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
+</code></pre>
+<p>Delete a file/folder</p>
+<pre><code class="language-sh">curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
+</code></pre>
+<p>Create a directory</p>
+<pre><code class="language-sh">curl -X MKCOL http://127.0.0.1:5000/path-to-folder
+</code></pre>
+<p>Move the file/folder to the new path</p>
+<pre><code class="language-sh">curl -X MOVE http://127.0.0.1:5000/path -H &quot;Destination: http://127.0.0.1:5000/new-path&quot;
+</code></pre>
+<p>List/search directory contents</p>
+<pre><code class="language-sh">curl http://127.0.0.1:5000?q=Dockerfile           # search for files, similar to `find -name Dockerfile`
+curl http://127.0.0.1:5000?simple                 # output names only, similar to `ls -1`
+curl http://127.0.0.1:5000?json                   # output paths in json format
+</code></pre>
+<p>With authorization (Both basic or digest auth works)</p>
+<pre><code class="language-sh">curl http://127.0.0.1:5000/file --user user:pass                 # basic auth
+curl http://127.0.0.1:5000/file --user user:pass --digest        # digest auth
+</code></pre>
+<p>Resumable downloads</p>
+<pre><code class="language-sh">curl -C- -o file http://127.0.0.1:5000/file
+</code></pre>
+<p>Resumable uploads</p>
+<pre><code class="language-sh">upload_offset=$(curl -I -s http://127.0.0.1:5000/file | tr -d '\r' | sed -n 's/content-length: //p')
+dd skip=$upload_offset if=file status=none ibs=1 | \
+  curl -X PATCH -H &quot;X-Update-Range: append&quot; --data-binary @- http://127.0.0.1:5000/file
+</code></pre>
+<p>Health checks</p>
+<pre><code class="language-sh">curl http://127.0.0.1:5000/__ozva-cloud__/health
+</code></pre>
+<h2>Advanced Topics</h2>
+<h3>Access Control</h3>
+<p>ozva-cloud supports account based access control. You can control who can do what on which path with <code>--auth</code>/<code>-a</code>.</p>
+<pre><code>ozva-cloud -a admin:admin@/:rw -a guest:guest@/
+ozva-cloud -a user:pass@/:rw,/dir1 -a @/
+</code></pre>
+<ol>
+<li>Use <code>@</code> to separate the account and paths. No account means anonymous user.</li>
+<li>Use <code>:</code> to separate the username and password of the account.</li>
+<li>Use <code>,</code> to separate paths.</li>
+<li>Use path suffix <code>:rw</code>/<code>:ro</code> set permissions: <code>read-write</code>/<code>read-only</code>. <code>:ro</code> can be omitted.</li>
+</ol>
+<ul>
+<li><code>-a admin:admin@/:rw</code>: <code>admin</code> has complete permissions for all paths.</li>
+<li><code>-a guest:guest@/</code>: <code>guest</code> has read-only permissions for all paths.</li>
+<li><code>-a user:pass@/:rw,/dir1</code>: <code>user</code> has read-write permissions for <code>/*</code>, has read-only permissions for <code>/dir1/*</code>.</li>
+<li><code>-a @/</code>: All paths is publicly accessible, everyone can view/download it.</li>
+</ul>
+<p><strong>Auth permissions are restricted by ozva-cloud global permissions.</strong> If ozva-cloud does not enable upload permissions via <code>--allow-upload</code>, then the account will not have upload permissions even if it is granted <code>read-write</code>(<code>:rw</code>) permissions.</p>
+<h4>Hashed Password</h4>
+<p>DUFS supports the use of sha-512 hashed password.</p>
+<p>Create hashed password:</p>
+<pre><code class="language-sh">$ openssl passwd -6 123456 # or `mkpasswd -m sha-512 123456`
+$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/
+</code></pre>
+<p>Use hashed password:</p>
+<pre><code class="language-sh">ozva-cloud -a 'admin:$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/@/:rw'
+</code></pre>
+<blockquote>
+<p>The hashed password contains <code>$6</code>, which can expand to a variable in some shells, so you have to use <strong>single quotes</strong> to wrap it.</p>
+</blockquote>
+<p>Two important things for hashed passwords:</p>
+<ol>
+<li>ozva-cloud only supports sha-512 hashed passwords, so ensure that the password string always starts with <code>$6$</code>.</li>
+<li>Digest authentication does not function properly with hashed passwords.</li>
+</ol>
+<h3>Hide Paths</h3>
+<p>ozva-cloud supports hiding paths from directory listings via option <code>--hidden &lt;glob&gt;,...</code>.</p>
+<pre><code>ozva-cloud --hidden .git,.DS_Store,tmp
+</code></pre>
+<blockquote>
+<p>The glob used in --hidden only matches file and directory names, not paths. So <code>--hidden dir1/file</code> is invalid.</p>
+</blockquote>
+<pre><code class="language-sh">ozva-cloud --hidden '.*'                          # hidden dotfiles
+ozva-cloud --hidden '*/'                          # hidden all folders
+ozva-cloud --hidden '*.log,*.lock'                # hidden by exts
+ozva-cloud --hidden '*.log' --hidden '*.lock'
+</code></pre>
+<h3>Log Format</h3>
+<p>ozva-cloud supports customize http log format with option <code>--log-format</code>.</p>
+<p>The log format can use following variables.</p>
+<table>
+<thead>
+<tr>
+<th>variable</th>
+<th>description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>$remote_addr</td>
+<td>client address</td>
+</tr>
+<tr>
+<td>$remote_user</td>
+<td>user name supplied with authentication</td>
+</tr>
+<tr>
+<td>$request</td>
+<td>full original request line</td>
+</tr>
+<tr>
+<td>$status</td>
+<td>response status</td>
+</tr>
+<tr>
+<td>$http_</td>
+<td>arbitrary request header field. examples: $http_user_agent, $http_referer</td>
+</tr>
+</tbody>
+</table>
+<p>The default log format is <code>'$remote_addr &quot;$request&quot; $status'</code>.</p>
+<pre><code>2022-08-06T06:59:31+08:00 INFO - 127.0.0.1 &quot;GET /&quot; 200
+</code></pre>
+<p>Disable http log</p>
+<pre><code>ozva-cloud --log-format=''
+</code></pre>
+<p>Log user-agent</p>
+<pre><code>ozva-cloud --log-format '$remote_addr &quot;$request&quot; $status $http_user_agent'
+</code></pre>
+<pre><code>2022-08-06T06:53:55+08:00 INFO - 127.0.0.1 &quot;GET /&quot; 200 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36
+</code></pre>
+<p>Log remote-user</p>
+<pre><code>ozva-cloud --log-format '$remote_addr $remote_user &quot;$request&quot; $status' -a /@admin:admin -a /folder1@user1:pass1
+</code></pre>
+<pre><code>2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin &quot;GET /&quot; 200
+</code></pre>
+<h2>Environment variables</h2>
+<p>All options can be set using environment variables prefixed with <code>DUFS_</code>.</p>
+<pre><code>[serve-path]                DUFS_SERVE_PATH=&quot;.&quot;
+    --config &lt;file&gt;         DUFS_CONFIG=config.yaml
+-b, --bind &lt;addrs&gt;          DUFS_BIND=0.0.0.0
+-p, --port &lt;port&gt;           DUFS_PORT=5000
+    --path-prefix &lt;path&gt;    DUFS_PATH_PREFIX=/ozva-cloud
+    --hidden &lt;value&gt;        DUFS_HIDDEN=tmp,*.log,*.lock
+-a, --auth &lt;rules&gt;          DUFS_AUTH=&quot;admin:admin@/:rw|@/&quot; 
+-A, --allow-all             DUFS_ALLOW_ALL=true
+    --allow-upload          DUFS_ALLOW_UPLOAD=true
+    --allow-delete          DUFS_ALLOW_DELETE=true
+    --allow-search          DUFS_ALLOW_SEARCH=true
+    --allow-symlink         DUFS_ALLOW_SYMLINK=true
+    --allow-archive         DUFS_ALLOW_ARCHIVE=true
+    --enable-cors           DUFS_ENABLE_CORS=true
+    --render-index          DUFS_RENDER_INDEX=true
+    --render-try-index      DUFS_RENDER_TRY_INDEX=true
+    --render-spa            DUFS_RENDER_SPA=true
+    --assets &lt;path&gt;         DUFS_ASSETS=./assets
+    --log-format &lt;format&gt;   DUFS_LOG_FORMAT=&quot;&quot;
+    --log-file &lt;file&gt;       DUFS_LOG_FILE=./ozva-cloud.log
+    --compress &lt;compress&gt;   DUFS_COMPRESS=low
+    --tls-cert &lt;path&gt;       DUFS_TLS_CERT=cert.pem
+    --tls-key &lt;path&gt;        DUFS_TLS_KEY=key.pem
+</code></pre>
+<h2>Configuration File</h2>
+<p>You can specify and use the configuration file by selecting the option <code>--config &lt;path-to-config.yaml&gt;</code>.</p>
+<p>The following are the configuration items:</p>
+<pre><code class="language-yaml">serve-path: '.'
+bind: 0.0.0.0
+port: 5000
+path-prefix: /ozva-cloud
+hidden:
+  - tmp
+  - '*.log'
+  - '*.lock'
+auth:
+  - admin:admin@/:rw
+  - user:pass@/src:rw,/share
+  - '@/'  # According to the YAML spec, quoting is required.
+allow-all: false
+allow-upload: true
+allow-delete: true
+allow-search: true
+allow-symlink: true
+allow-archive: true
+enable-cors: true
+render-index: true
+render-try-index: true
+render-spa: true
+assets: ./assets/
+log-format: '$remote_addr &quot;$request&quot; $status $http_user_agent'
+log-file: ./ozva-cloud.log
+compress: low
+tls-cert: tests/data/cert.pem
+tls-key: tests/data/key_pkcs1.pem
+</code></pre>
+<h3>Customize UI</h3>
+<p>ozva-cloud allows users to customize the UI with your own assets.</p>
+<pre><code>ozva-cloud --assets my-assets-dir/
+</code></pre>
diff --git a/README.md b/README.md
deleted file mode 100644 (file)
index 62ddbfe..0000000
--- a/README.md
+++ /dev/null
@@ -1,423 +0,0 @@
-# Dufs
-
-[![CI](https://github.com/sigoden/dufs/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/dufs/actions/workflows/ci.yaml)
-[![Crates](https://img.shields.io/crates/v/dufs.svg)](https://crates.io/crates/dufs)
-[![Docker Pulls](https://img.shields.io/docker/pulls/sigoden/dufs)](https://hub.docker.com/r/sigoden/dufs)
-
-Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav...
-
-![demo](https://user-images.githubusercontent.com/4012553/220513063-ff0f186b-ac54-4682-9af4-47a9781dee0d.png)
-
-## Features
-
-- Serve static files
-- Download folder as zip file
-- Upload files and folders (Drag & Drop)
-- Create/Edit/Search files
-- Resumable/partial uploads/downloads
-- Access control
-- Support https
-- Support webdav
-- Easy to use with curl
-
-## Install
-
-### With cargo
-
-```
-cargo install dufs
-```
-
-### With docker
-
-```
-docker run -v `pwd`:/data -p 5000:5000 --rm sigoden/dufs /data -A
-```
-
-### With [Homebrew](https://brew.sh)
-
-```
-brew install dufs
-```
-
-### Binaries on macOS, Linux, Windows
-
-Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip and add dufs to your $PATH.
-
-## CLI
-
-```
-Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
-
-Usage: dufs [OPTIONS] [serve-path]
-
-Arguments:
-  [serve-path]  Specific path to serve [default: .]
-
-Options:
-  -c, --config <file>        Specify configuration file
-  -b, --bind <addrs>         Specify bind address or unix socket
-  -p, --port <port>          Specify port to listen on [default: 5000]
-      --path-prefix <path>   Specify a path prefix
-      --hidden <value>       Hide paths from directory listings, e.g. tmp,*.log,*.lock
-  -a, --auth <rules>         Add auth roles, e.g. user:pass@/dir1:rw,/dir2
-  -A, --allow-all            Allow all operations
-      --allow-upload         Allow upload files/folders
-      --allow-delete         Allow delete files/folders
-      --allow-search         Allow search files/folders
-      --allow-symlink        Allow symlink to files/folders outside root directory
-      --allow-archive        Allow download folders as archive file
-      --enable-cors          Enable CORS, sets `Access-Control-Allow-Origin: *`
-      --render-index         Serve index.html when requesting a directory, returns 404 if not found index.html
-      --render-try-index     Serve index.html when requesting a directory, returns directory listing if not found index.html
-      --render-spa           Serve SPA(Single Page Application)
-      --assets <path>        Set the path to the assets directory for overriding the built-in assets
-      --log-format <format>  Customize http log format
-      --log-file <file>      Specify the file to save logs to, other than stdout/stderr
-      --compress <level>     Set zip compress level [default: low] [possible values: none, low, medium, high]
-      --completions <shell>  Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
-      --tls-cert <path>      Path to an SSL/TLS certificate to serve with HTTPS
-      --tls-key <path>       Path to the SSL/TLS certificate's private key
-  -h, --help                 Print help
-  -V, --version              Print version
-```
-
-## Examples
-
-Serve current working directory in read-only mode
-
-```
-dufs
-```
-
-Allow all operations like upload/delete/search/create/edit...
-
-```
-dufs -A
-```
-
-Only allow upload operation
-
-```
-dufs --allow-upload
-```
-
-Serve a specific directory
-
-```
-dufs Downloads
-```
-
-Serve a single file
-
-```
-dufs linux-distro.iso
-```
-
-Serve a single-page application like react/vue
-
-```
-dufs --render-spa
-```
-
-Serve a static website with index.html
-
-```
-dufs --render-index
-```
-
-Require username/password
-
-```
-dufs -a admin:123@/:rw
-```
-
-Listen on specific host:ip 
-
-```
-dufs -b 127.0.0.1 -p 80
-```
-
-Listen on unix socket
-```
-dufs -b /tmp/dufs.socket
-```
-
-Use https
-
-```
-dufs --tls-cert my.crt --tls-key my.key
-```
-
-## API
-
-Upload a file
-
-```sh
-curl -T path-to-file http://127.0.0.1:5000/new-path/path-to-file
-```
-
-Download a file
-```sh
-curl http://127.0.0.1:5000/path-to-file           # download the file
-curl http://127.0.0.1:5000/path-to-file?hash      # retrieve the sha256 hash of the file
-```
-
-Download a folder as zip file
-
-```sh
-curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
-```
-
-Delete a file/folder
-
-```sh
-curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
-```
-
-Create a directory
-
-```sh
-curl -X MKCOL http://127.0.0.1:5000/path-to-folder
-```
-
-Move the file/folder to the new path
-
-```sh
-curl -X MOVE http://127.0.0.1:5000/path -H "Destination: http://127.0.0.1:5000/new-path"
-```
-
-List/search directory contents
-
-```sh
-curl http://127.0.0.1:5000?q=Dockerfile           # search for files, similar to `find -name Dockerfile`
-curl http://127.0.0.1:5000?simple                 # output names only, similar to `ls -1`
-curl http://127.0.0.1:5000?json                   # output paths in json format
-```
-
-With authorization (Both basic or digest auth works)
-
-```sh
-curl http://127.0.0.1:5000/file --user user:pass                 # basic auth
-curl http://127.0.0.1:5000/file --user user:pass --digest        # digest auth
-```
-
-Resumable downloads
-
-```sh
-curl -C- -o file http://127.0.0.1:5000/file
-```
-
-Resumable uploads
-
-```sh
-upload_offset=$(curl -I -s http://127.0.0.1:5000/file | tr -d '\r' | sed -n 's/content-length: //p')
-dd skip=$upload_offset if=file status=none ibs=1 | \
-  curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file
-```
-
-Health checks
-
-```sh
-curl http://127.0.0.1:5000/__dufs__/health
-```
-
-<details>
-<summary><h2>Advanced Topics</h2></summary>
-
-### Access Control
-
-Dufs supports account based access control. You can control who can do what on which path with `--auth`/`-a`.
-
-```
-dufs -a admin:admin@/:rw -a guest:guest@/
-dufs -a user:pass@/:rw,/dir1 -a @/
-```
-
-1. Use `@` to separate the account and paths. No account means anonymous user.
-2. Use `:` to separate the username and password of the account.
-3. Use `,` to separate paths.
-4. Use path suffix `:rw`/`:ro` set permissions: `read-write`/`read-only`. `:ro` can be omitted.
-
-- `-a admin:admin@/:rw`: `admin` has complete permissions for all paths.
-- `-a guest:guest@/`: `guest` has read-only permissions for all paths.
-- `-a user:pass@/:rw,/dir1`: `user` has read-write permissions for `/*`, has read-only permissions for `/dir1/*`.
-- `-a @/`: All paths is publicly accessible, everyone can view/download it.
-
-**Auth permissions are restricted by dufs global permissions.** If dufs does not enable upload permissions via `--allow-upload`, then the account will not have upload permissions even if it is granted `read-write`(`:rw`) permissions.
-
-#### Hashed Password
-
-DUFS supports the use of sha-512 hashed password.
-
-Create hashed password:
-
-```sh
-$ openssl passwd -6 123456 # or `mkpasswd -m sha-512 123456`
-$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/
-```
-
-Use hashed password:
-
-```sh
-dufs -a 'admin:$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/@/:rw'
-```
-> The hashed password contains `$6`, which can expand to a variable in some shells, so you have to use **single quotes** to wrap it.
-
-Two important things for hashed passwords:
-
-1. Dufs only supports sha-512 hashed passwords, so ensure that the password string always starts with `$6$`.
-2. Digest authentication does not function properly with hashed passwords.
-
-
-### Hide Paths
-
-Dufs supports hiding paths from directory listings via option `--hidden <glob>,...`.
-
-```
-dufs --hidden .git,.DS_Store,tmp
-```
-
-> The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid.
-
-```sh
-dufs --hidden '.*'                          # hidden dotfiles
-dufs --hidden '*/'                          # hidden all folders
-dufs --hidden '*.log,*.lock'                # hidden by exts
-dufs --hidden '*.log' --hidden '*.lock'
-```
-
-### Log Format
-
-Dufs supports customize http log format with option `--log-format`.
-
-The log format can use following variables.
-
-| variable     | description                                                               |
-| ------------ | ------------------------------------------------------------------------- |
-| $remote_addr | client address                                                            |
-| $remote_user | user name supplied with authentication                                    |
-| $request     | full original request line                                                |
-| $status      | response status                                                           |
-| $http_       | arbitrary request header field. examples: $http_user_agent, $http_referer |
-
-
-The default log format is `'$remote_addr "$request" $status'`.
-```
-2022-08-06T06:59:31+08:00 INFO - 127.0.0.1 "GET /" 200
-```
-
-Disable http log
-```
-dufs --log-format=''
-```
-
-Log user-agent
-```
-dufs --log-format '$remote_addr "$request" $status $http_user_agent'
-```
-```
-2022-08-06T06:53:55+08:00 INFO - 127.0.0.1 "GET /" 200 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36
-```
-
-Log remote-user
-```
-dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admin -a /folder1@user1:pass1
-```
-```
-2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin "GET /" 200
-```
-
-## Environment variables
-
-All options can be set using environment variables prefixed with `DUFS_`.
-
-```
-[serve-path]                DUFS_SERVE_PATH="."
-    --config <file>         DUFS_CONFIG=config.yaml
--b, --bind <addrs>          DUFS_BIND=0.0.0.0
--p, --port <port>           DUFS_PORT=5000
-    --path-prefix <path>    DUFS_PATH_PREFIX=/dufs
-    --hidden <value>        DUFS_HIDDEN=tmp,*.log,*.lock
--a, --auth <rules>          DUFS_AUTH="admin:admin@/:rw|@/" 
--A, --allow-all             DUFS_ALLOW_ALL=true
-    --allow-upload          DUFS_ALLOW_UPLOAD=true
-    --allow-delete          DUFS_ALLOW_DELETE=true
-    --allow-search          DUFS_ALLOW_SEARCH=true
-    --allow-symlink         DUFS_ALLOW_SYMLINK=true
-    --allow-archive         DUFS_ALLOW_ARCHIVE=true
-    --enable-cors           DUFS_ENABLE_CORS=true
-    --render-index          DUFS_RENDER_INDEX=true
-    --render-try-index      DUFS_RENDER_TRY_INDEX=true
-    --render-spa            DUFS_RENDER_SPA=true
-    --assets <path>         DUFS_ASSETS=./assets
-    --log-format <format>   DUFS_LOG_FORMAT=""
-    --log-file <file>       DUFS_LOG_FILE=./dufs.log
-    --compress <compress>   DUFS_COMPRESS=low
-    --tls-cert <path>       DUFS_TLS_CERT=cert.pem
-    --tls-key <path>        DUFS_TLS_KEY=key.pem
-```
-
-## Configuration File
-
-You can specify and use the configuration file by selecting the option `--config <path-to-config.yaml>`.
-
-The following are the configuration items:
-
-```yaml
-serve-path: '.'
-bind: 0.0.0.0
-port: 5000
-path-prefix: /dufs
-hidden:
-  - tmp
-  - '*.log'
-  - '*.lock'
-auth:
-  - admin:admin@/:rw
-  - user:pass@/src:rw,/share
-  - '@/'  # According to the YAML spec, quoting is required.
-allow-all: false
-allow-upload: true
-allow-delete: true
-allow-search: true
-allow-symlink: true
-allow-archive: true
-enable-cors: true
-render-index: true
-render-try-index: true
-render-spa: true
-assets: ./assets/
-log-format: '$remote_addr "$request" $status $http_user_agent'
-log-file: ./dufs.log
-compress: low
-tls-cert: tests/data/cert.pem
-tls-key: tests/data/key_pkcs1.pem
-```
-
-### Customize UI
-
-Dufs allows users to customize the UI with your own assets.
-
-```
-dufs --assets my-assets-dir/
-```
-
-> If you only need to make slight adjustments to the current UI, you copy dufs's [assets](https://github.com/sigoden/dufs/tree/main/assets) directory and modify it accordingly. The current UI doesn't use any frameworks, just plain HTML/JS/CSS. As long as you have some basic knowledge of web development, it shouldn't be difficult to modify.
-
-Your assets folder must contains a `index.html` file.
-
-`index.html` can use the following placeholder variables to retrieve internal data.
-
-- `__INDEX_DATA__`: directory listing data
-- `__ASSETS_PREFIX__`: assets url prefix
-
-</details>
-
-## License
-
-Copyright (c) 2022-2024 dufs-developers.
-
-dufs is made available under the terms of either the MIT License or the Apache License 2.0, at your option.
-
-See the LICENSE-APACHE and LICENSE-MIT files for license details.
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644 (file)
index 861c70b..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# Security Policy
-
-## Supported Versions
-
-The latest release of *dufs* is supported. The fixes for any security issues found will be included
-in the next release.
-
-
-## Reporting a Vulnerability
-
-Please [use *dufs*'s security advisory reporting tool provided by
-GitHub](https://github.com/sigoden/dufs/security/advisories/new) to report security issues.
-
-We strive to fix security issues as quickly as possible. Across the industry, often the developers'
-slowness in developing and releasing a fix is the biggest delay in the process; we take pride in
-minimizing this delay as much as we practically can. We encourage you to also minimize the delay
-between when you find an issue and when you contact us. You do not need to convince us to take your
-report seriously. You don't need to create a PoC or a patch if that would slow down your reporting.
-You don't need an elaborate write-up. A short, informal note about the issue is good. We can always
-communicate later to fill in any details we need after that first note is shared with us.
-
index d2bb8d75d97fad3ccf43af1440a968df8cbb28ab..4e64927a60e0a868e1b07000e13555900936c998 100644 (file)
@@ -1,6 +1,7 @@
 :root {
   --lm-color: #004088;
   --dm-color: #004088;
+  --grid-size: 150px;
 }
 
 html {
@@ -20,6 +21,7 @@ body {
 }
 
 .head {
+  z-index:3;
   display: flex;
   flex-wrap: wrap;
   align-items: center;
@@ -36,7 +38,7 @@ body {
 }
 
 .breadcrumb>a {
-  color: #0366d6;
+  color: #0044aa;
   text-decoration: none;
 }
 
@@ -303,6 +305,120 @@ body {
   background-color: rgba(3, 47, 98, 0.2);
 }
 
+.thumbnail-preview {
+  display: inline-block;
+  vertical-align: top;
+  font-style: italic;
+  color: rgba(3, 47, 98, 0.2);
+}
+
+.thumbnail {
+  display: none;
+  position: absolute;
+  margin-left: -170px;
+  margin-top: -200px;
+  width: 200px;
+  height: 200px;
+  object-fit: contain;
+}
+
+.thumbnail-preview:hover > .thumbnail {
+  display: block;
+}
+
+.grid-mode {
+  display: block;
+  width: 100%;
+}
+
+.grid-mode .path a {
+  position: relative;
+  width: calc(var(--grid-size) - 10px);
+  min-width: var(--grid-size);
+  height: 25px;
+  margin-left: 10px;
+  padding-top: var(--grid-size);
+  display: inline-block;
+  z-index: 1;
+}
+
+.grid-mode > thead {
+  display: none;
+}
+.grid-mode > tbody {
+  display: block;
+}
+.grid-mode > tbody > tr {
+  display: inline-block;
+}
+.grid-mode > tbody > tr > td {
+  display: block;
+}
+.grid-mode > tbody > tr {
+  width: var(--grid-size);
+  height: calc(var(--grid-size) + 25px);
+  display: inline-block;
+}
+.grid-mode > tbody > tr > td:not(.cell-name, .cell-icon) {
+  display: none !important;
+}
+.grid-mode > tbody > tr > td.cell-name {
+  width: var(--grid-size);
+  height: calc(var(--grid-size) + 25px);
+  display: block;
+  margin-top: calc(var(--grid-size)*-1);
+}
+.grid-mode > tbody > tr > td.cell-name > * {
+  width: var(--grid-size);
+  height: calc(var(--grid-size) + 25px);
+  margin-bottom: calc(-1 * (var(--grid-size) + 25px));
+  margin-right: calc(var(--grid-size)*-1);
+}
+.grid-mode > tbody > tr > td.cell-name > .dropcopy {
+  display: none;
+}
+.grid-mode > tbody > tr > td.cell-name > .dropcopy {
+  display: none;
+}
+.grid-mode > tbody > tr > td.cell-name > .dropmove {
+  position: relative;
+  height: calc(var(--grid-size) + 25px);
+  color: rgba(3, 47, 98, 0.0);
+  border: 2px solid rgba(3, 47, 98, 0.0);
+  border-radius: 4px;
+  background-color: rgba(3, 47, 98, 0);
+}
+.grid-mode > tbody > tr > td.cell-name > .dropmove.dragging {
+  z-index:2;
+}
+.grid-mode > tbody > tr > td.cell-name > .dropmove.dragging.dragover {
+  background-color: rgba(3, 47, 98, 0.2);
+}
+.grid-mode > tbody > tr > td.cell-icon {
+  display: block;
+  width: var(--grid-size);
+  height: var(--grid-size);
+}
+.grid-mode > tbody > tr > td.cell-icon.has-preview {
+  opacity: 0;
+}
+.grid-mode > tbody > tr > td.cell-icon > svg {
+  width: var(--grid-size);
+  height: var(--grid-size);
+}
+.grid-mode > tbody > tr > td > .thumbnail-preview {
+}
+.grid-mode > tbody > tr > td > .thumbnail-preview > span {
+  display: none;
+}
+.grid-mode > tbody > tr > td > .thumbnail-preview > img {
+  width: var(--grid-size);
+  height: var(--grid-size);
+  display: block;
+  position: static;
+  margin: 0;
+}
+
 @media (min-width: 768px) {
   .path a {
     min-width: 400px;
@@ -358,3 +474,18 @@ body {
     color: white;
   }
 }
+
+@media print {
+  .path a {
+    color: #24292e;
+  }
+  .main {
+    padding: 2em;
+  }
+  .paths-table {
+    width: 100%;
+  }
+  .head, #filedrop, .cell-actions, th > a > span {
+    display: none !important;
+  }
+}
index de4e464d1839e4ede786f038a55fc73bb18a300d..a3e9c0d59c666e2a6af6034d879f731564fea96f 100644 (file)
             d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
         </svg>
       </div>
+      <div class="grid-btn hidden" title="Grid">
+      <svg
+   width="16"
+   height="16"
+   viewBox="0 0 16 16">
+  <path d="m 1.9042969,0.95703125 c -0.5185163,0 -0.94921878,0.43070255 -0.94921878,0.94921875 v 4.6953125 c 0,0.5185162 0.43070248,0.9492189 0.94921878,0.9492188 h 4.6953125 c 0.5185163,0 0.9492187,-0.4307026 0.9492187,-0.9492188 V 1.90625 c 0,-0.5185162 -0.4307024,-0.94921875 -0.9492187,-0.94921875 z m 7.4960937,0 c -0.5185162,0 -0.9511719,0.43070255 -0.9511719,0.94921875 v 4.6953125 c 0,0.5185162 0.4326557,0.9492189 0.9511719,0.9492188 H 14.09375 c 0.518516,0 0.949219,-0.4307026 0.949219,-0.9492188 V 1.90625 c 0,-0.5185162 -0.430703,-0.94921875 -0.949219,-0.94921875 z M 1.8925781,1.8554687 c 0.00376,-4.875e-4 0.00728,0 0.011719,0 h 4.6953125 c 0.01996,0 0.036866,0.00703 0.044922,0.017578 0.00456,0.00789 0.00586,0.019897 0.00586,0.033203 v 4.6953125 c 0,0.013307 -0.00325,0.02566 -0.00781,0.033203 -0.00806,0.01001 -0.023009,0.015625 -0.042969,0.015625 H 1.9042969 c -0.035484,0 -0.048828,-0.013344 -0.048828,-0.048828 V 1.90625 c 0,-0.01996 0.00757,-0.036866 0.017578,-0.044922 0.00503,-0.00304 0.01201,-0.00488 0.019531,-0.00586 z m 7.4960938,0 c 0.00381,-4.875e-4 0.00728,0 0.011719,0 h 4.6933601 c 0.01996,0 0.03687,0.00703 0.04492,0.017578 0.0046,0.00789 0.0059,0.019897 0.0059,0.033203 v 4.6953125 c 0,0.013307 -0.0033,0.02566 -0.0078,0.033203 -0.0081,0.01001 -0.02301,0.015625 -0.04297,0.015625 H 9.4003906 c -0.01996,0 -0.034913,-0.00757 -0.042969,-0.017578 -0.00456,-0.00754 -0.00781,-0.017943 -0.00781,-0.03125 V 1.90625 c 0,-0.013306 0.00521,-0.027269 0.00977,-0.035156 0.00627,-0.00821 0.015947,-0.013918 0.029297,-0.015625 z m -7.484375,6.5957032 c -0.5185163,0 -0.94921878,0.4307025 -0.94921878,0.9492187 v 4.6953124 c 0,0.518516 0.43070248,0.949219 0.94921878,0.949219 h 4.6953125 c 0.5185163,0 0.9492187,-0.430703 0.9492187,-0.949219 V 9.4003906 c 0,-0.5185162 -0.4307024,-0.9492187 -0.9492187,-0.9492187 z m 7.4960937,0 c -0.5185162,0 -0.9511719,0.4307025 -0.9511719,0.9492187 v 4.6953124 c 0,0.518516 0.4326557,0.949219 0.9511719,0.949219 H 14.09375 c 0.518516,0 0.949219,-0.430703 0.949219,-0.949219 V 9.4003906 c 0,-0.5185162 -0.430703,-0.9492187 -0.949219,-0.9492187 z M 1.8867187,9.3515625 c 0.00515,-9.962e-4 0.010925,0 0.017578,0 h 4.6953125 c 0.035484,0 0.050781,0.013344 0.050781,0.048828 v 4.6953125 c 0,0.0133 -0.00325,0.02566 -0.00781,0.0332 -0.00806,0.01001 -0.023009,0.01563 -0.042969,0.01563 H 1.9042969 c -0.035484,0 -0.048828,-0.01334 -0.048828,-0.04883 V 9.4003906 c 0,-0.01996 0.00757,-0.035454 0.017578,-0.042969 0.00377,-0.00211 0.00852,-0.00486 0.013672,-0.00586 z m 7.4960938,0 c 0.00527,-9.962e-4 0.010925,0 0.017578,0 H 14.09375 c 0.03548,0 0.05078,0.013344 0.05078,0.048828 v 4.6953125 c 0,0.0133 -0.0033,0.02566 -0.0078,0.0332 -0.0081,0.01001 -0.02301,0.01563 -0.04297,0.01563 H 9.4003906 c -0.01996,0 -0.034913,-0.0076 -0.042969,-0.01758 -0.00456,-0.0075 -0.00781,-0.01794 -0.00781,-0.03125 V 9.4003906 c 0,-0.028831 0.010383,-0.044511 0.033203,-0.048828 z" />
+</svg>
+    </div>
     </div>
     <form class="searchbar hidden">
       <div class="icon">
       </div>
     </div>
   </div>
+  <!--
+  <div class="info info-hidden">
+    <p>OzVa Cloud service</p>
+    <p><b>To-do</b></p>
+    <ul>
+      <li>Implement the arbiraty command system safely (important that its safe)</li>
+    </ul>
+  </div>
+  !-->
   <div class="main">
     <div class="index-page hidden">
       <div class="empty-folder hidden"></div>
diff --git a/assets/index.js b/assets/index.js
deleted file mode 100644 (file)
index d848c1d..0000000
+++ /dev/null
@@ -1,1117 +0,0 @@
-/**
-* @typedef {object} PathItem
-* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
-* @property {string} name
-* @property {number} mtime
-* @property {number} size
-*/
-
-/**
-* @typedef {object} DATA
-* @property {string} href
-* @property {string} uri_prefix
-* @property {"Index" | "Edit" | "View"} kind
-* @property {PathItem[]} paths
-* @property {boolean} allow_upload
-* @property {boolean} allow_delete
-* @property {boolean} allow_search
-* @property {boolean} allow_archive
-* @property {boolean} auth
-* @property {string} user
-* @property {boolean} dir_exists
-* @property {string} editable
-*/
-
-var OZVA_MAX_UPLOADINGS = 1;
-
-/**
-* @type {DATA} DATA
-*/
-var DATA;
-
-/**
-* @type {string}
-*/
-var DIR_EMPTY_NOTE;
-
-/**
-* @type {PARAMS}
-* @typedef {object} PARAMS
-* @property {string} q
-* @property {string} sort
-* @property {string} order
-*/
-const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
-
-const IFRAME_FORMATS = [
-       ".pdf",
-       ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg",
-       ".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm",
-       ".mp3", ".ogg", ".wav", ".m4a",
-];
-
-const MAX_SUBPATHS_COUNT = 1000;
-
-const ICONS = {
-       dir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`,
-       symlinkFile: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`,
-       symlinkDir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`,
-       file: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`,
-       download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
-       move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
-       edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
-       delete: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
-       view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
-}
-
-/**
-* @type Map<string, Uploader>
-*/
-const failUploaders = new Map();
-
-/**
-* @type Element
-*/
-let $pathsTable;
-/**
-* @type Element
-*/
-let $pathsTableHead;
-/**
-* @type Element
-*/
-let $pathsTableBody;
-/**
-* @type Element
-*/
-let $uploadersTable;
-/**
-* @type Element
-*/
-let $emptyFolder;
-/**
-* @type Element
-*/
-let $editor;
-/**
-* @type Element
-*/
-let $loginBtn;
-/**
-* @type Element
-*/
-let $logoutBtn;
-/**
-* @type Element
-*/
-let $userName;
-
-// Produce table when window loads
-window.addEventListener("DOMContentLoaded", async () => {
-       const $indexData = document.getElementById('index-data');
-       if (!$indexData) {
-               alert("No data");
-               return;
-       }
-
-       DATA = JSON.parse(decodeBase64($indexData.innerHTML));
-       DIR_EMPTY_NOTE = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
-
-       await ready();
-});
-
-function makeDrag ( e ) {
-       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
-               e.addEventListener(name, ev => {
-                       ev.stopPropagation();
-               });
-       });
-
-       e.addEventListener("dragstart", ev => {
-               ev.dataTransfer.effectAllowed = 'move';
-
-               ev.dataTransfer.setData('text/plain', e.dataset.i);
-
-               for (zone of document.getElementsByClassName("internaldrop")) {
-                       zone.classList.add("dragging");
-               }
-
-               console.log("drag start");
-       });
-
-       e.addEventListener("dragend", (ev) => {
-               if (ev.target.classList.contains("dropzone")) {
-                       ev.target.classList.remove("dragover");
-               }
-
-               for (zone of document.getElementsByClassName("internaldrop")) {
-                       zone.classList.remove("dragging");
-                       zone.classList.remove("dragover");
-               }
-       },);
-}
-
-function makeDrop ( e ) {
-       e.addEventListener("dragenter", (ev) => {
-               ev.stopPropagation();
-               if (ev.target.classList.contains("internaldrop")) {
-                       ev.target.classList.add("dragover");
-               }
-       },);
-
-       e.addEventListener("dragleave", (ev) => {
-               ev.stopPropagation();
-               if (ev.target.classList.contains("dragover")) {
-                       ev.target.classList.remove("dragover");
-               }
-       },);
-
-       e.addEventListener("dragover", (ev) => {
-               ev.preventDefault()
-       },);
-
-       let method;
-       if (e.classList.contains("dropcopy")) {
-               method = "COPY";
-       } else {
-               method = "MOVE";
-       }
-       e.addEventListener("drop", getDrop(method, e),);
-}
-
-var getDrop = function ( method, e ) {
-       return async function doDrop (ev) {
-               ev.stopPropagation();
-
-               const index = ev.dataTransfer.getData("text/plain");
-               const file = DATA.paths[index];
-               if (!file) return;
-               const fileUrl = newUrl(file.name);
-               const fileName = fileUrl.split("/").at(-1)
-
-               const dir = DATA.paths[e.dataset.i];
-               if (!dir) return;
-               const regex = /\/[^\/]+\/\.\./i;
-               const dirUrl = newUrl(dir.name).replace(regex, "");
-
-               let newFileUrl = `${dirUrl}/${fileName}`;
-
-               try {
-                       await checkAuth();
-                       const res1 = await fetch(newFileUrl, {
-                               method: "HEAD",
-                       });
-                       if (res1.status === 200) {
-                               if (!confirm("Override existing file?")) {
-                                       return;
-                               }
-                       }
-                       const res2 = await fetch(fileUrl, {
-                               method: method,
-                               headers: {
-                                       "Destination": newFileUrl,
-                               }
-                       });
-                       await assertResOK(res2);
-
-                       if (method == "MOVE") {
-                               document.getElementById(`addPath${index}`)?.remove();
-                               DATA.paths[index] = null;
-                               if (!DATA.paths.find(v => !!v)) {
-                                       $pathsTable.classList.add("hidden");
-                                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
-                                       $emptyFolder.classList.remove("hidden");
-                               }
-                       }
-
-               } catch (err) {
-                       alert(`Cannot ${method.toLowerCase()} \`${fileUrl}\` to \`${newFileUrl}\`, ${err.message}`);
-               }
-       }
-}
-
-async function ready() {
-       $pathsTable = document.querySelector(".paths-table");
-       $pathsTableHead = document.querySelector(".paths-table thead");
-       $pathsTableBody = document.querySelector(".paths-table tbody");
-       $uploadersTable = document.querySelector(".uploaders-table");
-       $emptyFolder = document.querySelector(".empty-folder");
-       $editor = document.querySelector(".editor");
-       $loginBtn = document.querySelector(".login-btn");
-       $logoutBtn = document.querySelector(".logout-btn");
-       $userName = document.querySelector(".user-name");
-
-       addBreadcrumb(DATA.href, DATA.uri_prefix);
-
-       if (DATA.kind === "Index") {
-               document.title = `Index of ${DATA.href} - OzVa Cloud`;
-               document.querySelector(".index-page").classList.remove("hidden");
-
-               await setupIndexPage();
-
-               // make files draggable
-               for (e of document.querySelectorAll('[draggable="true"]')) {
-                       makeDrag(e);
-               }
-
-               for (e of document.getElementsByClassName('internaldrop')) {
-                       makeDrop(e);
-               }
-
-       } else if (DATA.kind === "Edit") {
-               document.title = `Edit ${DATA.href} - OzVa Cloud`;
-               document.querySelector(".editor-page").classList.remove("hidden");
-
-               await setupEditorPage();
-       } else if (DATA.kind === "View") {
-               document.title = `View ${DATA.href} - OzVa Cloud`;
-               document.querySelector(".editor-page").classList.remove("hidden");
-
-               await setupEditorPage();
-       }
-}
-
-class Uploader {
-       /**
-       *
-       * @param {File} file
-       * @param {string[]} pathParts
-       */
-       constructor(file, pathParts) {
-               /**
-               * @type Element
-               */
-               this.$uploadStatus = null
-               this.uploaded = 0;
-               this.uploadOffset = 0;
-               this.lastUptime = 0;
-               this.name = [...pathParts, file.name].join("/");
-               this.idx = Uploader.globalIdx++;
-               this.file = file;
-               this.url = newUrl(this.name);
-       }
-
-       upload() {
-               const { idx, name, url } = this;
-               const encodedName = encodedStr(name);
-               $uploadersTable.insertAdjacentHTML("beforeend", `
-       <tr id="upload${idx}" class="uploader">
-               <td class="path cell-icon">
-                       ${getPathSvg()}
-               </td>
-               <td class="path cell-name">
-                       <a href="${url}">${encodedName}</a>
-               </td>
-               <td class="cell-status upload-status" id="uploadStatus${idx}"></td>
-       </tr>`);
-               $uploadersTable.classList.remove("hidden");
-               $emptyFolder.classList.add("hidden");
-               this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
-               this.$uploadStatus.innerHTML = '-';
-               this.$uploadStatus.addEventListener("click", e => {
-                       const nodeId = e.target.id;
-                       const matches = /^retry(\d+)$/.exec(nodeId);
-                       if (matches) {
-                               const id = parseInt(matches[1]);
-                               let uploader = failUploaders.get(id);
-                               if (uploader) uploader.retry();
-                       }
-               });
-               Uploader.queues.push(this);
-               Uploader.runQueue();
-       }
-
-       ajax() {
-               const { url } = this;
-
-               this.uploaded = 0;
-               this.lastUptime = Date.now();
-
-               const ajax = new XMLHttpRequest();
-               ajax.upload.addEventListener("progress", e => this.progress(e), false);
-               ajax.addEventListener("readystatechange", () => {
-                       if (ajax.readyState === 4) {
-                               if (ajax.status >= 200 && ajax.status < 300) {
-                                       this.complete();
-                               } else {
-                                       if (ajax.status != 0) {
-                                               this.fail(`${ajax.status} ${ajax.statusText}`);
-                                       }
-                               }
-                       }
-               })
-               ajax.addEventListener("error", () => this.fail(), false);
-               ajax.addEventListener("abort", () => this.fail(), false);
-               if (this.uploadOffset > 0) {
-                       ajax.open("PATCH", url);
-                       ajax.setRequestHeader("X-Update-Range", "append");
-                       ajax.send(this.file.slice(this.uploadOffset));
-               } else {
-                       ajax.open("PUT", url);
-                       ajax.send(this.file);
-                       // setTimeout(() => ajax.abort(), 3000);
-               }
-       }
-
-       async retry() {
-               const { url } = this;
-               let res = await fetch(url, {
-                       method: "HEAD",
-               });
-               let uploadOffset = 0;
-               if (res.status == 200) {
-                       let value = res.headers.get("content-length");
-                       uploadOffset = parseInt(value) || 0;
-               }
-               this.uploadOffset = uploadOffset;
-               this.ajax();
-       }
-
-       progress(event) {
-               const now = Date.now();
-               const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
-               const [speedValue, speedUnit] = formatFileSize(speed);
-               const speedText = `${speedValue} ${speedUnit}/s`;
-               const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
-               const duration = formatDuration((event.total - event.loaded) / speed);
-               this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
-               this.uploaded = event.loaded;
-               this.lastUptime = now;
-       }
-
-       complete() {
-               const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
-               $uploadStatusNew.innerHTML = `✓`;
-               this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
-               this.$uploadStatus = null;
-               failUploaders.delete(this.idx);
-               Uploader.runnings--;
-               Uploader.runQueue();
-       }
-
-       fail(reason = "") {
-               this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
-               failUploaders.set(this.idx, this);
-               Uploader.runnings--;
-               Uploader.runQueue();
-       }
-}
-
-Uploader.globalIdx = 0;
-
-Uploader.runnings = 0;
-
-Uploader.auth = false;
-
-/**
-* @type Uploader[]
-*/
-Uploader.queues = [];
-
-
-Uploader.runQueue = async () => {
-       if (Uploader.runnings >= OZVA_MAX_UPLOADINGS) return;
-       if (Uploader.queues.length == 0) return;
-       Uploader.runnings++;
-       let uploader = Uploader.queues.shift();
-       if (!Uploader.auth) {
-               Uploader.auth = true;
-               try {
-                       await checkAuth();
-               } catch {
-                       Uploader.auth = false;
-               }
-       }
-       uploader.ajax();
-}
-
-/**
-* Add breadcrumb
-* @param {string} href
-* @param {string} uri_prefix
-*/
-function addBreadcrumb(href, uri_prefix) {
-       const $breadcrumb = document.querySelector(".breadcrumb");
-       let parts = [];
-       if (href === "/") {
-               parts = [""];
-       } else {
-               parts = href.split("/");
-       }
-       const len = parts.length;
-       let path = uri_prefix;
-       for (let i = 0; i < len; i++) {
-               const name = parts[i];
-               if (i > 0) {
-                       if (!path.endsWith("/")) {
-                               path += "/";
-                       }
-                       path += encodeURIComponent(name);
-               }
-               const encodedName = encodedStr(name);
-               if (i === 0) {
-                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}" title="Root"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
-               } else if (i === len - 1) {
-                       $breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
-               } else {
-                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}">${encodedName}</a>`);
-               }
-               if (i !== len - 1) {
-                       $breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
-               }
-       }
-}
-
-async function setupIndexPage() {
-       if (DATA.allow_archive) {
-               const $download = document.querySelector(".download");
-               $download.href = baseUrl() + "?zip";
-               $download.title = "Download folder as a .zip file";
-               $download.classList.remove("hidden");
-       }
-
-       if (DATA.allow_upload) {
-               setupDropzone();
-               setupUploadFile();
-               setupNewFolder();
-               setupNewFile();
-       }
-
-       if (DATA.auth) {
-               await setupAuth();
-       }
-
-       if (DATA.allow_search) {
-               setupSearch();
-       }
-
-       console.log(DATA.paths);
-
-       renderPathsTableHead();
-       renderPathsTableBody();
-}
-
-/**
-* Render path table thead
-*/
-function renderPathsTableHead() {
-       const headerItems = [
-               {
-                       name: "name",
-                       props: `colspan="2"`,
-                       text: "Name",
-               },
-               {
-                       name: "mtime",
-                       props: ``,
-                       text: "Last Modified",
-               },
-               {
-                       name: "size",
-                       props: ``,
-                       text: "Size",
-               }
-       ];
-       $pathsTableHead.insertAdjacentHTML("beforeend", `
-               <tr>
-                       ${headerItems.map(item => {
-               let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
-               let order = "desc";
-               if (PARAMS.sort === item.name) {
-                       if (PARAMS.order === "desc") {
-                               order = "asc";
-                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
-                       } else {
-                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
-                       }
-               }
-               const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
-               const icon = `<span>${svg}</span>`
-               return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
-       }).join("\n")}
-                       <th class="cell-actions">Actions</th>
-               </tr>
-       `);
-}
-
-/**
-* Render path table tbody
-*/
-function renderPathsTableBody() {
-       if (DATA.paths && DATA.paths.length > 0) {
-               const len = DATA.paths.length;
-               if (len > 0) {
-                       $pathsTable.classList.remove("hidden");
-               }
-               for (let i = 0; i < len; i++) {
-                       addPath(DATA.paths[i], i);
-               }
-       } else {
-               $emptyFolder.textContent = DIR_EMPTY_NOTE;
-               $emptyFolder.classList.remove("hidden");
-       }
-}
-
-/**
-* Add pathitem
-* @param {PathItem} file
-* @param {number} index
-*/
-function addPath(file, index) {
-       const encodedName = encodedStr(file.name);
-       let url = newUrl(file.name);
-       let actionDelete = "";
-       let actionDownload = "";
-       let actionMove = "";
-       let actionEdit = "";
-       let actionView = "";
-       let isDir = file.path_type.endsWith("Dir");
-       let isParent = (file.name == "..");
-       if (isDir) {
-               url += "/";
-               if (DATA.allow_archive) {
-                       actionDownload = `
-                       <div class="action-btn">
-                               <a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
-                       </div>`;
-               }
-       } else {
-               actionDownload = `
-               <div class="action-btn" >
-                       <a href="${url}" title="Download file" download>${ICONS.download}</a>
-               </div>`;
-       }
-       if (DATA.allow_delete) {
-               if (DATA.allow_upload) {
-                       actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
-                       if (!isDir) {
-                               actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
-                       }
-               }
-               actionDelete = `
-               <div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
-       }
-       if (!actionEdit && !isDir) {
-               actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
-       }
-       let actionCell = `
-       <td class="cell-actions">
-               ${actionDownload}
-               ${actionView}
-               ${actionMove}
-               ${actionDelete}
-               ${actionEdit}
-       </td>`;
-
-       let sizeDisplay = isDir ? formatDirSize(file.size) : formatFileSize(file.size).join(" ");
-
-       $pathsTableBody.insertAdjacentHTML("beforeend", `
-<tr id="addPath${index}">
-       <td class="path cell-icon">
-                       ${getPathSvg(file.path_type)}
-       </td>
-       <td class="path cell-name">
-               <div class="drag-div" ${isParent ? "" : `draggable="true"`} data-i="${index}">
-                       <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
-               </div>
-               ${isDir ? `
-<div class="internaldrop dropcopy" data-i="${index}">Copy</div>
-<div class="internaldrop dropmove" data-i="${index}">Move</div>
-               ` : ''}
-       </td>
-       <td class="cell-mtime">${formatMtime(file.mtime)}</td>
-       <td class="cell-size">${sizeDisplay}</td>
-       ${actionCell}
-</tr>`);
-}
-
-function setupDropzone() {
-       const dropZone = document.getElementById("filedrop");
-       dropZone.addEventListener("dragenter", e => {
-               e.stopPropagation();
-
-               if (!e.dataTransfer.getData("text/plain")) {
-                       dropZone.classList.add("dragoverfile");
-               }
-       });
-       dropZone.addEventListener("dragleave", e => {
-               e.stopPropagation();
-
-               dropZone.classList.remove("dragoverfile");
-       });
-
-       ["dragend", "dragleave"].forEach(name => {
-               document.body.addEventListener(name, e => {
-                       e.preventDefault();
-                       e.stopPropagation();
-
-                       if ("dragging" in dropZone.classList) {
-                               dropZone.classList.remove("dragging");
-                       }
-               });
-       });
-
-       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
-               dropZone.addEventListener(name, e => {
-                       e.preventDefault();
-                       e.stopPropagation();
-               });
-       });
-       dropZone.addEventListener("drop", async e => {
-               dropZone.classList.remove("dragging");
-
-               if (!e.dataTransfer.items[0].webkitGetAsEntry) {
-                       const files = Array.from(e.dataTransfer.files).filter(v => v.size > 0);
-                       for (const file of files) {
-                               new Uploader(file, []).upload();
-                       }
-               } else {
-                       const entries = [];
-                       const len = e.dataTransfer.items.length;
-                       for (let i = 0; i < len; i++) {
-                               entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
-                       }
-                       addFileEntries(entries, []);
-               }
-       });
-}
-
-async function setupAuth() {
-       if (DATA.user) {
-               $logoutBtn.classList.remove("hidden");
-               $logoutBtn.addEventListener("click", logout);
-               $userName.textContent = DATA.user;
-       } else {
-               $loginBtn.classList.remove("hidden");
-               $loginBtn.addEventListener("click", async () => {
-                       try {
-                               await checkAuth();
-                       } catch {}
-                       location.reload();
-               });
-       }
-}
-
-function setupSearch() {
-       const $searchbar = document.querySelector(".searchbar");
-       $searchbar.classList.remove("hidden");
-       $searchbar.addEventListener("submit", event => {
-               event.preventDefault();
-               const formData = new FormData($searchbar);
-               const q = formData.get("q");
-               let href = baseUrl();
-               if (q) {
-                       href += "?q=" + q;
-               }
-               location.href = href;
-       });
-       if (PARAMS.q) {
-               document.getElementById('search').value = PARAMS.q;
-       }
-}
-
-function setupUploadFile() {
-       document.querySelector(".upload-file").classList.remove("hidden");
-       document.getElementById("file").addEventListener("change", async e => {
-               const files = e.target.files;
-               for (let file of files) {
-                       new Uploader(file, []).upload();
-               }
-       });
-}
-
-function setupNewFolder() {
-       const $newFolder = document.querySelector(".new-folder");
-       $newFolder.classList.remove("hidden");
-       $newFolder.addEventListener("click", () => {
-               const name = prompt("Enter folder name");
-               if (name) createFolder(name);
-       });
-}
-
-function setupNewFile() {
-       const $newFile = document.querySelector(".new-file");
-       $newFile.classList.remove("hidden");
-       $newFile.addEventListener("click", () => {
-               const name = prompt("Enter file name");
-               if (name) createFile(name);
-       });
-}
-
-async function setupEditorPage() {
-       const url = baseUrl();
-
-       const $download = document.querySelector(".download");
-       $download.classList.remove("hidden");
-       $download.href = url;
-
-       if (DATA.kind == "Edit") {
-               const $moveFile = document.querySelector(".move-file");
-               $moveFile.classList.remove("hidden");
-               $moveFile.addEventListener("click", async () => {
-                       const query = location.href.slice(url.length);
-                       const newFileUrl = await doMovePath(url);
-                       if (newFileUrl) {
-                               location.href = newFileUrl + query;
-                       }
-               });
-
-               const $deleteFile = document.querySelector(".delete-file");
-               $deleteFile.classList.remove("hidden");
-               $deleteFile.addEventListener("click", async () => {
-                       const url = baseUrl();
-                       const name = baseName(url);
-                       await doDeletePath(name, url, () => {
-                               location.href = location.href.split("/").slice(0, -1).join("/");
-                       });
-               });
-
-               if (DATA.editable) {
-                       const $saveBtn = document.querySelector(".save-btn");
-                       $saveBtn.classList.remove("hidden");
-                       $saveBtn.addEventListener("click", saveChange);
-               }
-       } else if (DATA.kind == "View") {
-               $editor.readonly = true;
-       }
-
-       if (!DATA.editable) {
-               const $notEditable = document.querySelector(".not-editable");
-               const url = baseUrl();
-               const ext = extName(baseName(url));
-               if (IFRAME_FORMATS.find(v => v === ext)) {
-                       $notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`);
-               } else {
-                       $notEditable.classList.remove("hidden");
-                       $notEditable.textContent = "Cannot edit because file is too large or binary.";
-               }
-               return;
-       }
-
-       $editor.classList.remove("hidden");
-       try {
-               const res = await fetch(baseUrl());
-               await assertResOK(res);
-               const encoding = getEncoding(res.headers.get("content-type"));
-               if (encoding === "utf-8") {
-                       $editor.value = await res.text();
-               } else {
-                       const bytes = await res.arrayBuffer();
-                       const dataView = new DataView(bytes);
-                       const decoder = new TextDecoder(encoding);
-                       $editor.value = decoder.decode(dataView);
-               }
-       } catch (err) {
-               alert(`Failed get file, ${err.message}`);
-       }
-}
-
-/**
-* Delete path
-* @param {number} index
-* @returns
-*/
-async function deletePath(index) {
-       const file = DATA.paths[index];
-       if (!file) return;
-       await doDeletePath(file.name, newUrl(file.name), () => {
-               document.getElementById(`addPath${index}`)?.remove();
-               DATA.paths[index] = null;
-               if (!DATA.paths.find(v => !!v)) {
-                       $pathsTable.classList.add("hidden");
-                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
-                       $emptyFolder.classList.remove("hidden");
-               }
-       });
-}
-
-async function doDeletePath(name, url, cb) {
-       if (!confirm(`Delete \`${name}\`?`)) return;
-       try {
-               await checkAuth();
-               const res = await fetch(url, {
-                       method: "DELETE",
-               });
-               await assertResOK(res);
-               cb();
-       } catch (err) {
-               alert(`Cannot delete \`${file.name}\`, ${err.message}`);
-       }
-}
-
-/**
-* Move path
-* @param {number} index
-* @returns
-*/
-async function movePath(index) {
-       const file = DATA.paths[index];
-       if (!file) return;
-       const fileUrl = newUrl(file.name);
-       const newFileUrl = await doMovePath(fileUrl);
-       if (newFileUrl) {
-               location.href = newFileUrl.split("/").slice(0, -1).join("/");
-       }
-}
-
-async function doMovePath(fileUrl) {
-       const fileUrlObj = new URL(fileUrl);
-
-       const prefix = DATA.uri_prefix.slice(0, -1);
-
-       const filePath = decodeURIComponent(fileUrlObj.pathname.slice(prefix.length));
-
-       let newPath = prompt("Enter new path", filePath);
-       if (!newPath) return;
-       if (!newPath.startsWith("/")) newPath = "/" + newPath;
-       if (filePath === newPath) return;
-       const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
-
-       try {
-               await checkAuth();
-               const res1 = await fetch(newFileUrl, {
-                       method: "HEAD",
-               });
-               if (res1.status === 200) {
-                       if (!confirm("Override existing file?")) {
-                               return;
-                       }
-               }
-               const res2 = await fetch(fileUrl, {
-                       method: "MOVE",
-                       headers: {
-                               "Destination": newFileUrl,
-                       }
-               });
-               await assertResOK(res2);
-               return newFileUrl;
-       } catch (err) {
-               alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
-       }
-}
-
-
-/**
-* Save editor change
-*/
-async function saveChange() {
-       try {
-               await fetch(baseUrl(), {
-                       method: "PUT",
-                       body: $editor.value,
-               });
-               location.reload();
-       } catch (err) {
-               alert(`Failed to save file, ${err.message}`);
-       }
-}
-
-async function checkAuth() {
-       if (!DATA.auth) return;
-       const res = await fetch(baseUrl(), {
-               method: "CHECKAUTH",
-       });
-       await assertResOK(res);
-       $loginBtn.classList.add("hidden");
-       $logoutBtn.classList.remove("hidden");
-       $userName.textContent = await res.text();
-}
-
-function logout() {
-       if (!DATA.auth) return;
-       const url = baseUrl();
-       const xhr = new XMLHttpRequest();
-       xhr.open("LOGOUT", url, true, DATA.user);
-       xhr.onload = () => {
-               location.href = url;
-       }
-       xhr.send();
-}
-
-/**
-* Create a folder
-* @param {string} name
-*/
-async function createFolder(name) {
-       const url = newUrl(name);
-       try {
-               await checkAuth();
-               const res = await fetch(url, {
-                       method: "MKCOL",
-               });
-               await assertResOK(res);
-               location.href = url;
-       } catch (err) {
-               alert(`Cannot create folder \`${name}\`, ${err.message}`);
-       }
-}
-
-async function createFile(name) {
-       const url = newUrl(name);
-       try {
-               await checkAuth();
-               const res = await fetch(url, {
-                       method: "PUT",
-                       body: "",
-               });
-               await assertResOK(res);
-               location.href = url + "?edit";
-       } catch (err) {
-               alert(`Cannot create file \`${name}\`, ${err.message}`);
-       }
-}
-
-async function addFileEntries(entries, dirs) {
-       for (const entry of entries) {
-               if (entry == null) {
-                       // this is the case if the user has dragged a file into the file upload area
-                       // this can be made unlikely with fun css
-                       return
-               } else if (entry.isFile) {
-                       entry.file(file => {
-                               new Uploader(file, dirs).upload();
-                       });
-               } else if (entry.isDirectory) {
-                       const dirReader = entry.createReader();
-
-                       const successCallback = entries => {
-                               if (entries.length > 0) {
-                                       addFileEntries(entries, [...dirs, entry.name]);
-                                       dirReader.readEntries(successCallback);
-                               }
-                       };
-
-                       dirReader.readEntries(successCallback);
-               }
-       }
-}
-
-
-function newUrl(name) {
-       let url = baseUrl();
-       if (!url.endsWith("/")) url += "/";
-       url += name.split("/").map(encodeURIComponent).join("/");
-       return url;
-}
-
-function baseUrl() {
-       return location.href.split(/[?#]/)[0];
-}
-
-function baseName(url) {
-       return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0]);
-}
-
-function extName(filename) {
-       const dotIndex = filename.lastIndexOf('.');
-
-       if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
-               return '';
-       }
-
-       return filename.substring(dotIndex);
-}
-
-function getPathSvg(path_type) {
-       switch (path_type) {
-               case "Dir":
-                       return ICONS.dir;
-               case "SymlinkFile":
-                       return ICONS.symlinkFile;
-               case "SymlinkDir":
-                       return ICONS.symlinkDir;
-               default:
-                       return ICONS.file;
-       }
-}
-
-function formatMtime(mtime) {
-       if (!mtime) return "";
-       const date = new Date(mtime);
-       const year = date.getFullYear();
-       const month = padZero(date.getMonth() + 1, 2);
-       const day = padZero(date.getDate(), 2);
-       const hours = padZero(date.getHours(), 2);
-       const minutes = padZero(date.getMinutes(), 2);
-       return `${year}-${month}-${day} ${hours}:${minutes}`;
-}
-
-function padZero(value, size) {
-       return ("0".repeat(size) + value).slice(-1 * size);
-}
-
-function formatDirSize(size) {
-       const unit = size === 1 ? "item" : "items";
-       const num = size >= MAX_SUBPATHS_COUNT ? `>${MAX_SUBPATHS_COUNT - 1}` : `${size}`;
-       return ` ${num} ${unit}`;
-}
-
-function formatFileSize(size) {
-       if (size == null) return [0, "B"];
-       const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
-       if (size == 0) return [0, "B"];
-       const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
-       let ratio = 1;
-       if (i >= 3) {
-               ratio = 100;
-       }
-       return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
-}
-
-function formatDuration(seconds) {
-       seconds = Math.ceil(seconds);
-       const h = Math.floor(seconds / 3600);
-       const m = Math.floor((seconds - h * 3600) / 60);
-       const s = seconds - h * 3600 - m * 60;
-       return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
-}
-
-function formatPercent(percent) {
-       if (percent > 10) {
-               return percent.toFixed(1) + "%";
-       } else {
-               return percent.toFixed(2) + "%";
-       }
-}
-
-function encodedStr(rawStr) {
-       return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
-               return '&#' + i.charCodeAt(0) + ';';
-       });
-}
-
-async function assertResOK(res) {
-       if (!(res.status >= 200 && res.status < 300)) {
-               throw new Error(await res.text() || `Invalid status ${res.status}`);
-       }
-}
-
-function getEncoding(contentType) {
-       const charset = contentType?.split(";")[1];
-       if (/charset/i.test(charset)) {
-               let encoding = charset.split("=")[1];
-               if (encoding) {
-                       return encoding.toLowerCase();
-               }
-       }
-       return 'utf-8';
-}
-
-// Parsing base64 strings with Unicode characters
-function decodeBase64(base64String) {
-       const binString = atob(base64String);
-       const len = binString.length;
-       const bytes = new Uint8Array(len);
-       const arr = new Uint32Array(bytes.buffer, 0, Math.floor(len / 4));
-       let i = 0;
-       for (; i < arr.length; i++) {
-               arr[i] = binString.charCodeAt(i * 4) |
-                       (binString.charCodeAt(i * 4 + 1) << 8) |
-                       (binString.charCodeAt(i * 4 + 2) << 16) |
-                       (binString.charCodeAt(i * 4 + 3) << 24);
-       }
-       for (i = i * 4; i < len; i++) {
-               bytes[i] = binString.charCodeAt(i);
-       }
-       return new TextDecoder().decode(bytes);
-}
diff --git a/backup.js b/backup.js
new file mode 100644 (file)
index 0000000..feaa11a
--- /dev/null
+++ b/backup.js
@@ -0,0 +1,1173 @@
+/**
+* @typedef {object} PathItem
+* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
+* @property {string} name
+* @property {number} mtime
+* @property {number} size
+*/
+
+/**
+* @typedef {object} DATA
+* @property {string} href
+* @property {string} uri_prefix
+* @property {"Index" | "Edit" | "View"} kind
+* @property {PathItem[]} paths
+* @property {boolean} allow_upload
+* @property {boolean} allow_delete
+* @property {boolean} allow_search
+* @property {boolean} allow_archive
+* @property {boolean} auth
+* @property {string} user
+* @property {boolean} dir_exists
+* @property {string} editable
+*/
+
+var OZVA_MAX_UPLOADINGS = 1;
+
+/**
+* @type {DATA} DATA
+*/
+var DATA;
+
+/**
+* @type {string}
+*/
+var DIR_EMPTY_NOTE;
+
+/**
+* @type {PARAMS}
+* @typedef {object} PARAMS
+* @property {string} q
+* @property {string} sort
+* @property {string} order
+*/
+const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
+
+const IFRAME_FORMATS = [
+       ".pdf",
+       ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg",
+       ".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm",
+       ".mp3", ".ogg", ".wav", ".m4a",
+];
+
+const MAX_SUBPATHS_COUNT = 1000;
+
+const ICONS = {
+       dir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`,
+       symlinkFile: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`,
+       symlinkDir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`,
+       file: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`,
+       download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
+       move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
+       edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
+       delete: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
+       view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
+       duplicate: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M 3.6269531 0 C 2.6755659 0 1.9003906 0.78091373 1.9003906 1.7324219 L 1.9003906 12.058594 C 1.9003906 13.010102 2.6755661 13.791016 3.6269531 13.791016 L 3.9667969 13.791016 L 3.9667969 14.267578 C 3.9667969 15.219396 4.7423239 16 5.6933594 16 L 12.554688 16 C 13.505722 16 14.283203 15.219396 14.283203 14.267578 L 14.283203 3.9414062 C 14.283203 2.9895887 13.505722 2.2089844 12.554688 2.2089844 L 12.216797 2.2089844 L 12.216797 1.7324219 C 12.216797 0.7800921 11.439668 -1.4802974e-16 10.488281 0 L 3.6269531 0 z M 3.6269531 0.8984375 L 10.488281 0.8984375 C 10.958544 0.8984375 11.318359 1.2583282 11.318359 1.7324219 L 11.318359 2.1503906 L 5.6503906 2.1503906 C 4.6998277 2.1503906 3.9238281 2.9293513 3.9238281 3.8808594 L 3.9238281 12.892578 L 3.6269531 12.892578 C 3.1566901 12.892578 2.7988281 12.533509 2.7988281 12.058594 L 2.7988281 1.7324219 C 2.7988281 1.2575066 3.1566903 0.8984375 3.6269531 0.8984375 z M 5.6933594 3.109375 L 12.554688 3.109375 C 13.025301 3.109375 13.382812 3.4668003 13.382812 3.9414062 L 13.382812 14.267578 C 13.382812 14.742185 13.025301 15.099609 12.554688 15.099609 L 5.6933594 15.099609 C 5.2227448 15.099609 4.8652344 14.742184 4.8652344 14.267578 L 4.8652344 3.9414062 C 4.8652344 3.4668004 5.2227452 3.109375 5.6933594 3.109375 z"/></svg>`
+}
+
+/**
+* @type Map<string, Uploader>
+*/
+const failUploaders = new Map();
+
+/**
+* @type Element
+*/
+let $pathsTable;
+/**
+* @type Element
+*/
+let $pathsTableHead;
+/**
+* @type Element
+*/
+let $pathsTableBody;
+/**
+* @type Element
+*/
+let $uploadersTable;
+/**
+* @type Element
+*/
+let $emptyFolder;
+/**
+* @type Element
+*/
+let $editor;
+/**
+* @type Element
+*/
+let $loginBtn;
+/**
+* @type Element
+*/
+let $logoutBtn;
+/**
+* @type Element
+*/
+let $userName;
+
+// Produce table when window loads
+window.addEventListener("DOMContentLoaded", async () => {
+       const $indexData = document.getElementById('index-data');
+       if (!$indexData) {
+               alert("No data");
+               return;
+       }
+
+       DATA = JSON.parse(decodeBase64($indexData.innerHTML));
+       DIR_EMPTY_NOTE = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
+
+       await ready();
+});
+
+function makeDrag ( e ) {
+       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
+               e.addEventListener(name, ev => {
+                       ev.stopPropagation();
+               });
+       });
+
+       e.addEventListener("dragstart", ev => {
+               ev.dataTransfer.effectAllowed = 'move';
+
+               ev.dataTransfer.setData('text/plain', e.dataset.i);
+
+               for (zone of document.getElementsByClassName("internaldrop")) {
+                       zone.classList.add("dragging");
+               }
+
+               console.log("drag start");
+       });
+
+       e.addEventListener("dragend", (ev) => {
+               if (ev.target.classList.contains("dropzone")) {
+                       ev.target.classList.remove("dragover");
+               }
+
+               for (zone of document.getElementsByClassName("internaldrop")) {
+                       zone.classList.remove("dragging");
+                       zone.classList.remove("dragover");
+               }
+       },);
+}
+
+function makeDrop ( e ) {
+       e.addEventListener("dragenter", (ev) => {
+               ev.stopPropagation();
+               if (ev.target.classList.contains("internaldrop")) {
+                       ev.target.classList.add("dragover");
+               }
+       },);
+
+       e.addEventListener("dragleave", (ev) => {
+               ev.stopPropagation();
+               if (ev.target.classList.contains("dragover")) {
+                       ev.target.classList.remove("dragover");
+               }
+       },);
+
+       e.addEventListener("dragover", (ev) => {
+               ev.preventDefault()
+       },);
+
+       let method;
+       if (e.classList.contains("dropcopy")) {
+               method = "COPY";
+       } else {
+               method = "MOVE";
+       }
+       e.addEventListener("drop", getDrop(method, e),);
+}
+
+var getDrop = function ( method, e ) {
+       return async function doDrop (ev) {
+               ev.stopPropagation();
+               const index = ev.dataTransfer.getData("text/plain");
+
+               if (index == e.dataset.i) {return};
+
+               const file = DATA.paths[index];
+               if (!file) return;
+               const fileUrl = newUrl(file.name);
+               const fileName = fileUrl.split("/").at(-1)
+
+               const dir = DATA.paths[e.dataset.i];
+               if (!dir) return;
+               const regex = /\/[^\/]+\/\.\./i;
+               const dirUrl = newUrl(dir.name).replace(regex, "");
+
+               let newFileUrl = `${dirUrl}/${fileName}`;
+
+               try {
+                       await checkAuth();
+                       const res1 = await fetch(newFileUrl, {
+                               method: "HEAD",
+                       });
+                       if (res1.status === 200) {
+                               if (!confirm("Override existing file?")) {
+                                       return;
+                               }
+                       }
+                       const res2 = await fetch(fileUrl, {
+                               method: method,
+                               headers: {
+                                       "Destination": newFileUrl,
+                               }
+                       });
+                       await assertResOK(res2);
+
+                       if (method == "MOVE") {
+                               document.getElementById(`addPath${index}`)?.remove();
+                               DATA.paths[index] = null;
+                               if (!DATA.paths.find(v => !!v)) {
+                                       $pathsTable.classList.add("hidden");
+                                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
+                                       $emptyFolder.classList.remove("hidden");
+                               }
+                       }
+
+               } catch (err) {
+                       alert(`Cannot ${method.toLowerCase()} \`${fileUrl}\` to \`${newFileUrl}\`, ${err.message}`);
+               }
+       }
+}
+
+async function duplicate (index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       const fileUrl = newUrl(file.name);
+
+       let fileParts = fileUrl.split("/");
+       let origionalName = fileParts.pop();
+       let lastPart = `Copy%20of%20${origionalName}`;
+
+       let newFile = file;
+       newFile.name = `Copy of ${origionalName}`;
+
+       fileParts.push(lastPart);
+       newFileUrl = fileParts.join("/")
+
+       try {
+               await checkAuth();
+               const res1 = await fetch(newFileUrl, {
+                       method: "HEAD",
+               });
+               if (res1.status === 200) {
+                       if (!confirm("Override existing file?")) {
+                               return;
+                       }
+               }
+               const res2 = await fetch(fileUrl, {
+                       method: "COPY",
+                       headers: {
+                               "Destination": newFileUrl,
+                       }
+               });
+               await assertResOK(res2);
+
+               window.location.reload(); // This is a bad idea! i think ! it does work tho so im not sure
+
+       } catch (err) {
+               alert(`Cannot copy \`${fileUrl}\` to \`${newFileUrl}\`, ${err.message}`);
+       }
+}
+
+async function ready() {
+       $pathsTable = document.querySelector(".paths-table");
+       $pathsTableHead = document.querySelector(".paths-table thead");
+       $pathsTableBody = document.querySelector(".paths-table tbody");
+       $uploadersTable = document.querySelector(".uploaders-table");
+       $emptyFolder = document.querySelector(".empty-folder");
+       $editor = document.querySelector(".editor");
+       $loginBtn = document.querySelector(".login-btn");
+       $logoutBtn = document.querySelector(".logout-btn");
+       $userName = document.querySelector(".user-name");
+
+       addBreadcrumb(DATA.href, DATA.uri_prefix);
+
+       if (DATA.kind === "Index") {
+               document.title = `Index of ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".index-page").classList.remove("hidden");
+
+               await setupIndexPage();
+
+               // make files draggable
+               for (e of document.querySelectorAll('[draggable="true"]')) {
+                       makeDrag(e);
+               }
+
+               for (e of document.getElementsByClassName('internaldrop')) {
+                       makeDrop(e);
+               }
+
+       } else if (DATA.kind === "Edit") {
+               document.title = `Edit ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".editor-page").classList.remove("hidden");
+
+               await setupEditorPage();
+       } else if (DATA.kind === "View") {
+               document.title = `View ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".editor-page").classList.remove("hidden");
+
+               await setupEditorPage();
+       }
+}
+
+class Uploader {
+       /**
+       *
+       * @param {File} file
+       * @param {string[]} pathParts
+       */
+       constructor(file, pathParts) {
+               /**
+               * @type Element
+               */
+               this.$uploadStatus = null
+               this.uploaded = 0;
+               this.uploadOffset = 0;
+               this.lastUptime = 0;
+               this.name = [...pathParts, file.name].join("/");
+               this.idx = Uploader.globalIdx++;
+               this.file = file;
+               this.url = newUrl(this.name);
+       }
+
+       upload() {
+               const { idx, name, url } = this;
+               const encodedName = encodedStr(name);
+               $uploadersTable.insertAdjacentHTML("beforeend", `
+       <tr id="upload${idx}" class="uploader">
+               <td class="path cell-icon">
+                       ${getPathSvg()}
+               </td>
+               <td class="path cell-name">
+                       <a href="${url}">${encodedName}</a>
+               </td>
+               <td class="cell-status upload-status" id="uploadStatus${idx}"></td>
+       </tr>`);
+               $uploadersTable.classList.remove("hidden");
+               $emptyFolder.classList.add("hidden");
+               this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
+               this.$uploadStatus.innerHTML = '-';
+               this.$uploadStatus.addEventListener("click", e => {
+                       const nodeId = e.target.id;
+                       const matches = /^retry(\d+)$/.exec(nodeId);
+                       if (matches) {
+                               const id = parseInt(matches[1]);
+                               let uploader = failUploaders.get(id);
+                               if (uploader) uploader.retry();
+                       }
+               });
+               Uploader.queues.push(this);
+               Uploader.runQueue();
+       }
+
+       ajax() {
+               const { url } = this;
+
+               this.uploaded = 0;
+               this.lastUptime = Date.now();
+
+               const ajax = new XMLHttpRequest();
+               ajax.upload.addEventListener("progress", e => this.progress(e), false);
+               ajax.addEventListener("readystatechange", () => {
+                       if (ajax.readyState === 4) {
+                               if (ajax.status >= 200 && ajax.status < 300) {
+                                       this.complete();
+                               } else {
+                                       if (ajax.status != 0) {
+                                               this.fail(`${ajax.status} ${ajax.statusText}`);
+                                       }
+                               }
+                       }
+               })
+               ajax.addEventListener("error", () => this.fail(), false);
+               ajax.addEventListener("abort", () => this.fail(), false);
+               if (this.uploadOffset > 0) {
+                       ajax.open("PATCH", url);
+                       ajax.setRequestHeader("X-Update-Range", "append");
+                       ajax.send(this.file.slice(this.uploadOffset));
+               } else {
+                       ajax.open("PUT", url);
+                       ajax.send(this.file);
+                       // setTimeout(() => ajax.abort(), 3000);
+               }
+       }
+
+       async retry() {
+               const { url } = this;
+               let res = await fetch(url, {
+                       method: "HEAD",
+               });
+               let uploadOffset = 0;
+               if (res.status == 200) {
+                       let value = res.headers.get("content-length");
+                       uploadOffset = parseInt(value) || 0;
+               }
+               this.uploadOffset = uploadOffset;
+               this.ajax();
+       }
+
+       progress(event) {
+               const now = Date.now();
+               const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
+               const [speedValue, speedUnit] = formatFileSize(speed);
+               const speedText = `${speedValue} ${speedUnit}/s`;
+               const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
+               const duration = formatDuration((event.total - event.loaded) / speed);
+               this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
+               this.uploaded = event.loaded;
+               this.lastUptime = now;
+       }
+
+       complete() {
+               const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
+               $uploadStatusNew.innerHTML = `✓`;
+               this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
+               this.$uploadStatus = null;
+               failUploaders.delete(this.idx);
+               Uploader.runnings--;
+               Uploader.runQueue();
+       }
+
+       fail(reason = "") {
+               this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
+               failUploaders.set(this.idx, this);
+               Uploader.runnings--;
+               Uploader.runQueue();
+       }
+}
+
+Uploader.globalIdx = 0;
+
+Uploader.runnings = 0;
+
+Uploader.auth = false;
+
+/**
+* @type Uploader[]
+*/
+Uploader.queues = [];
+
+
+Uploader.runQueue = async () => {
+       if (Uploader.runnings >= OZVA_MAX_UPLOADINGS) return;
+       if (Uploader.queues.length == 0) return;
+       Uploader.runnings++;
+       let uploader = Uploader.queues.shift();
+       if (!Uploader.auth) {
+               Uploader.auth = true;
+               try {
+                       await checkAuth();
+               } catch {
+                       Uploader.auth = false;
+               }
+       }
+       uploader.ajax();
+}
+
+/**
+* Add breadcrumb
+* @param {string} href
+* @param {string} uri_prefix
+*/
+function addBreadcrumb(href, uri_prefix) {
+       const $breadcrumb = document.querySelector(".breadcrumb");
+       let parts = [];
+       if (href === "/") {
+               parts = [""];
+       } else {
+               parts = href.split("/");
+       }
+       const len = parts.length;
+       let path = uri_prefix;
+       for (let i = 0; i < len; i++) {
+               const name = parts[i];
+               if (i > 0) {
+                       if (!path.endsWith("/")) {
+                               path += "/";
+                       }
+                       path += encodeURIComponent(name);
+               }
+               const encodedName = encodedStr(name);
+               if (i === 0) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}" title="Root"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
+               } else if (i === len - 1) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
+               } else {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}">${encodedName}</a>`);
+               }
+               if (i !== len - 1) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
+               }
+       }
+}
+
+async function setupIndexPage() {
+       if (DATA.allow_archive) {
+               const $download = document.querySelector(".download");
+               $download.href = baseUrl() + "?zip";
+               $download.title = "Download folder as a .zip file";
+               $download.classList.remove("hidden");
+       }
+
+       if (DATA.allow_upload) {
+               setupDropzone();
+               setupUploadFile();
+               setupNewFolder();
+               setupNewFile();
+       }
+
+       if (DATA.auth) {
+               await setupAuth();
+       }
+
+       if (DATA.allow_search) {
+               setupSearch();
+       }
+
+       setupGridButton();
+       renderPathsTableHead();
+       renderPathsTableBody();
+}
+
+/**
+* Render path table thead
+*/
+function renderPathsTableHead() {
+       const headerItems = [
+               {
+                       name: "name",
+                       props: `colspan="2"`,
+                       text: "Name",
+               },
+               {
+                       name: "mtime",
+                       props: ``,
+                       text: "Last Modified",
+               },
+               {
+                       name: "size",
+                       props: ``,
+                       text: "Size",
+               }
+       ];
+       $pathsTableHead.insertAdjacentHTML("beforeend", `
+               <tr>
+                       ${headerItems.map(item => {
+               let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
+               let order = "desc";
+               if (PARAMS.sort === item.name) {
+                       if (PARAMS.order === "desc") {
+                               order = "asc";
+                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
+                       } else {
+                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
+                       }
+               }
+               const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
+               const icon = `<span>${svg}</span>`
+               return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
+       }).join("\n")}
+                       <th class="cell-actions">Actions</th>
+               </tr>
+       `);
+}
+
+/**
+* Render path table tbody
+*/
+function renderPathsTableBody() {
+       if (DATA.paths && DATA.paths.length > 0) {
+               const len = DATA.paths.length;
+               if (len > 0) {
+                       $pathsTable.classList.remove("hidden");
+               }
+               for (let i = 0; i < len; i++) {
+                       addPath(DATA.paths[i], i);
+               }
+       } else {
+               $emptyFolder.textContent = DIR_EMPTY_NOTE;
+               $emptyFolder.classList.remove("hidden");
+       }
+}
+
+/**
+* Add pathitem
+* @param {PathItem} file
+* @param {number} index
+*/
+function addPath(file, index) {
+       const encodedName = encodedStr(file.name);
+       let url = newUrl(file.name);
+       let actionDelete = "";
+       let actionDownload = "";
+       let actionMove = "";
+       let actionEdit = "";
+       let actionView = "";
+       let isDir = file.path_type.endsWith("Dir");
+       let isParent = (file.name == "..");
+       let isImage = (["png", "jpg", "svg"].includes(url.slice(-3).toLowerCase()));
+       let isDocument = (url.slice(-3).toLowerCase() == "pdf");
+       if (isDir) {
+               url += "/";
+               if (DATA.allow_archive) {
+                       actionDownload = `
+                       <div class="action-btn">
+                               <a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
+                       </div>`;
+               }
+       } else {
+               actionDownload = `
+               <div class="action-btn" >
+                       <a href="${url}" title="Download file" download>${ICONS.download}</a>
+               </div>`;
+       }
+       if (DATA.allow_delete) {
+               if (DATA.allow_upload) {
+                       actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
+                       if (!isDir) {
+                               actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
+                       }
+               }
+               actionDelete = `
+               <div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
+       }
+       if (!actionEdit && !isDir) {
+               actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
+       }
+       let actionPreview = "";
+       if (isImage && !isDir) {
+               actionPreview = `<div class="thumbnail-preview"><span>[Preview]</span><img class='thumbnail' loading='lazy' src='${url}' /></div>`;
+       }
+       let actionCell = `
+       <td class="cell-actions">
+               ${actionDownload}
+               ${actionView}
+               ${actionMove}
+               <div onclick="duplicate(${index})" class="action-btn" id="duplicateBtn${index}" title="Duplicate">${ICONS.duplicate}</div>
+               ${actionDelete}
+               ${actionEdit}
+       </td>`;
+
+       let sizeDisplay = isDir ? formatDirSize(file.size) : formatFileSize(file.size).join(" ");
+
+       $pathsTableBody.insertAdjacentHTML("beforeend", `
+<tr id="addPath${index}">
+       <td class="path cell-icon ${isImage ? 'has-preview' : ''}">
+                       ${getPathSvg(file.path_type)}
+       </td>
+       <td class="path cell-name">
+               <div class="drag-div" ${isParent ? "" : `draggable="true"`} data-i="${index}">
+                       <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
+               </div>${actionPreview}
+${isDir ? `
+       <div class="internaldrop dropcopy" data-i="${index}">Copy</div>
+       <div class="internaldrop dropmove" data-i="${index}">Move</div>
+` : ''}
+       </td>
+       <td class="cell-mtime">${formatMtime(file.mtime)}</td>
+       <td class="cell-size">${sizeDisplay}</td>
+       ${actionCell}
+</tr>`);
+}
+
+function setupDropzone() {
+       const dropZone = document.getElementById("filedrop");
+       dropZone.addEventListener("dragenter", e => {
+               e.stopPropagation();
+
+               if (!e.dataTransfer.getData("text/plain")) {
+                       dropZone.classList.add("dragoverfile");
+               }
+       });
+       dropZone.addEventListener("dragleave", e => {
+               e.stopPropagation();
+
+               dropZone.classList.remove("dragoverfile");
+       });
+
+       ["dragend", "dragleave"].forEach(name => {
+               document.body.addEventListener(name, e => {
+                       e.preventDefault();
+                       e.stopPropagation();
+
+                       if ("dragging" in dropZone.classList) {
+                               dropZone.classList.remove("dragging");
+                       }
+               });
+       });
+
+       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
+               dropZone.addEventListener(name, e => {
+                       e.preventDefault();
+                       e.stopPropagation();
+               });
+       });
+       dropZone.addEventListener("drop", async e => {
+               dropZone.classList.remove("dragging");
+
+               if (!e.dataTransfer.items[0].webkitGetAsEntry) {
+                       const files = Array.from(e.dataTransfer.files).filter(v => v.size > 0);
+                       for (const file of files) {
+                               new Uploader(file, []).upload();
+                       }
+               } else {
+                       const entries = [];
+                       const len = e.dataTransfer.items.length;
+                       for (let i = 0; i < len; i++) {
+                               entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
+                       }
+                       addFileEntries(entries, []);
+               }
+       });
+}
+
+async function setupAuth() {
+       if (DATA.user) {
+               $logoutBtn.classList.remove("hidden");
+               $logoutBtn.addEventListener("click", logout);
+               $userName.textContent = DATA.user;
+       } else {
+               $loginBtn.classList.remove("hidden");
+               $loginBtn.addEventListener("click", async () => {
+                       try {
+                               await checkAuth();
+                       } catch {}
+                       location.reload();
+               });
+       }
+}
+
+function setupSearch() {
+       const $searchbar = document.querySelector(".searchbar");
+       $searchbar.classList.remove("hidden");
+       $searchbar.addEventListener("submit", event => {
+               event.preventDefault();
+               const formData = new FormData($searchbar);
+               const q = formData.get("q");
+               let href = baseUrl();
+               if (q) {
+                       href += "?q=" + q;
+               }
+               location.href = href;
+       });
+       if (PARAMS.q) {
+               document.getElementById('search').value = PARAMS.q;
+       }
+}
+
+function setupUploadFile() {
+       document.querySelector(".upload-file").classList.remove("hidden");
+       document.getElementById("file").addEventListener("change", async e => {
+               const files = e.target.files;
+               for (let file of files) {
+                       new Uploader(file, []).upload();
+               }
+       });
+}
+
+function setupNewFolder() {
+       const $newFolder = document.querySelector(".new-folder");
+       $newFolder.classList.remove("hidden");
+       $newFolder.addEventListener("click", () => {
+               const name = prompt("Enter folder name");
+               if (name) createFolder(name);
+       });
+}
+
+function setupNewFile() {
+       const $newFile = document.querySelector(".new-file");
+       $newFile.classList.remove("hidden");
+       $newFile.addEventListener("click", () => {
+               const name = prompt("Enter file name");
+               if (name) createFile(name);
+       });
+}
+
+function setupGridButton() {
+       const $gridButton = document.querySelector(".grid-btn");
+       $gridButton.classList.remove("hidden");
+       $gridButton.addEventListener("click", () => {
+               const $table = document.querySelector(".paths-table");
+               $table.classList.toggle("grid-mode");
+       });
+}
+
+async function setupEditorPage() {
+       const url = baseUrl();
+
+       const $download = document.querySelector(".download");
+       $download.classList.remove("hidden");
+       $download.href = url;
+
+       if (DATA.kind == "Edit") {
+               const $moveFile = document.querySelector(".move-file");
+               $moveFile.classList.remove("hidden");
+               $moveFile.addEventListener("click", async () => {
+                       const query = location.href.slice(url.length);
+                       const newFileUrl = await doMovePath(url);
+                       if (newFileUrl) {
+                               location.href = newFileUrl + query;
+                       }
+               });
+
+               const $deleteFile = document.querySelector(".delete-file");
+               $deleteFile.classList.remove("hidden");
+               $deleteFile.addEventListener("click", async () => {
+                       const url = baseUrl();
+                       const name = baseName(url);
+                       await doDeletePath(name, url, () => {
+                               location.href = location.href.split("/").slice(0, -1).join("/");
+                       });
+               });
+
+               if (DATA.editable) {
+                       const $saveBtn = document.querySelector(".save-btn");
+                       $saveBtn.classList.remove("hidden");
+                       $saveBtn.addEventListener("click", saveChange);
+               }
+       } else if (DATA.kind == "View") {
+               $editor.readonly = true;
+       }
+
+       if (!DATA.editable) {
+               const $notEditable = document.querySelector(".not-editable");
+               const url = baseUrl();
+               const ext = extName(baseName(url));
+               if (IFRAME_FORMATS.find(v => v === ext)) {
+                       $notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`);
+               } else {
+                       $notEditable.classList.remove("hidden");
+                       $notEditable.textContent = "Cannot edit because file is too large or binary.";
+               }
+               return;
+       }
+
+       $editor.classList.remove("hidden");
+       try {
+               const res = await fetch(baseUrl());
+               await assertResOK(res);
+               const encoding = getEncoding(res.headers.get("content-type"));
+               if (encoding === "utf-8") {
+                       $editor.value = await res.text();
+               } else {
+                       const bytes = await res.arrayBuffer();
+                       const dataView = new DataView(bytes);
+                       const decoder = new TextDecoder(encoding);
+                       $editor.value = decoder.decode(dataView);
+               }
+       } catch (err) {
+               alert(`Failed get file, ${err.message}`);
+       }
+}
+
+/**
+* Delete path
+* @param {number} index
+* @returns
+*/
+async function deletePath(index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       await doDeletePath(file.name, newUrl(file.name), () => {
+               document.getElementById(`addPath${index}`)?.remove();
+               DATA.paths[index] = null;
+               if (!DATA.paths.find(v => !!v)) {
+                       $pathsTable.classList.add("hidden");
+                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
+                       $emptyFolder.classList.remove("hidden");
+               }
+       });
+}
+
+async function doDeletePath(name, url, cb) {
+       if (!confirm(`Delete \`${name}\`?`)) return;
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "DELETE",
+               });
+               await assertResOK(res);
+               cb();
+       } catch (err) {
+               alert(`Cannot delete \`${file.name}\`, ${err.message}`);
+       }
+}
+
+/**
+* Move path
+* @param {number} index
+* @returns
+*/
+async function movePath(index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       const fileUrl = newUrl(file.name);
+       const newFileUrl = await doMovePath(fileUrl);
+       if (newFileUrl) {
+               location.href = newFileUrl.split("/").slice(0, -1).join("/");
+       }
+}
+
+async function doMovePath(fileUrl) {
+       const fileUrlObj = new URL(fileUrl);
+
+       const prefix = DATA.uri_prefix.slice(0, -1);
+
+       const filePath = decodeURIComponent(fileUrlObj.pathname.slice(prefix.length));
+
+       let newPath = prompt("Enter new path", filePath);
+       if (!newPath) return;
+       if (!newPath.startsWith("/")) newPath = "/" + newPath;
+       if (filePath === newPath) return;
+       const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
+
+       try {
+               await checkAuth();
+               const res1 = await fetch(newFileUrl, {
+                       method: "HEAD",
+               });
+               if (res1.status === 200) {
+                       if (!confirm("Override existing file?")) {
+                               return;
+                       }
+               }
+               const res2 = await fetch(fileUrl, {
+                       method: "MOVE",
+                       headers: {
+                               "Destination": newFileUrl,
+                       }
+               });
+               await assertResOK(res2);
+               return newFileUrl;
+       } catch (err) {
+               alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
+       }
+}
+
+
+/**
+* Save editor change
+*/
+async function saveChange() {
+       try {
+               await fetch(baseUrl(), {
+                       method: "PUT",
+                       body: $editor.value,
+               });
+               location.reload();
+       } catch (err) {
+               alert(`Failed to save file, ${err.message}`);
+       }
+}
+
+async function checkAuth() {
+       if (!DATA.auth) return;
+       const res = await fetch(baseUrl(), {
+               method: "CHECKAUTH",
+       });
+       await assertResOK(res);
+       $loginBtn.classList.add("hidden");
+       $logoutBtn.classList.remove("hidden");
+       $userName.textContent = await res.text();
+}
+
+function logout() {
+       if (!DATA.auth) return;
+       const url = baseUrl();
+       const xhr = new XMLHttpRequest();
+       xhr.open("LOGOUT", url, true, DATA.user);
+       xhr.onload = () => {
+               location.href = url;
+       }
+       xhr.send();
+}
+
+/**
+* Create a folder
+* @param {string} name
+*/
+async function createFolder(name) {
+       const url = newUrl(name);
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "MKCOL",
+               });
+               await assertResOK(res);
+               location.href = url;
+       } catch (err) {
+               alert(`Cannot create folder \`${name}\`, ${err.message}`);
+       }
+}
+
+async function createFile(name) {
+       const url = newUrl(name);
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "PUT",
+                       body: "",
+               });
+               await assertResOK(res);
+               location.href = url + "?edit";
+       } catch (err) {
+               alert(`Cannot create file \`${name}\`, ${err.message}`);
+       }
+}
+
+async function addFileEntries(entries, dirs) {
+       for (const entry of entries) {
+               if (entry == null) {
+                       // this is the case if the user has dragged a file into the file upload area
+                       // this can be made unlikely with fun css
+                       return
+               } else if (entry.isFile) {
+                       entry.file(file => {
+                               new Uploader(file, dirs).upload();
+                       });
+               } else if (entry.isDirectory) {
+                       const dirReader = entry.createReader();
+
+                       const successCallback = entries => {
+                               if (entries.length > 0) {
+                                       addFileEntries(entries, [...dirs, entry.name]);
+                                       dirReader.readEntries(successCallback);
+                               }
+                       };
+
+                       dirReader.readEntries(successCallback);
+               }
+       }
+}
+function newUrl(name) {
+       let url = baseUrl();
+       if (!url.endsWith("/")) url += "/";
+       url += name.split("/").map(encodeURIComponent).join("/");
+       return url;
+}
+
+function baseUrl() {
+       return location.href.split(/[?#]/)[0];
+}
+
+function baseName(url) {
+       return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0]);
+}
+
+function extName(filename) {
+       const dotIndex = filename.lastIndexOf('.');
+
+       if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
+               return '';
+       }
+
+       return filename.substring(dotIndex);
+}
+
+function getPathSvg(path_type) {
+       switch (path_type) {
+               case "Dir":
+                       return ICONS.dir;
+               case "SymlinkFile":
+                       return ICONS.symlinkFile;
+               case "SymlinkDir":
+                       return ICONS.symlinkDir;
+               default:
+                       return ICONS.file;
+       }
+}
+
+function formatMtime(mtime) {
+       if (!mtime) return "";
+       const date = new Date(mtime);
+       const year = date.getFullYear();
+       const month = padZero(date.getMonth() + 1, 2);
+       const day = padZero(date.getDate(), 2);
+       const hours = padZero(date.getHours(), 2);
+       const minutes = padZero(date.getMinutes(), 2);
+       return `${year}-${month}-${day} ${hours}:${minutes}`;
+}
+
+function padZero(value, size) {
+       return ("0".repeat(size) + value).slice(-1 * size);
+}
+
+function formatDirSize(size) {
+       const unit = size === 1 ? "item" : "items";
+       const num = size >= MAX_SUBPATHS_COUNT ? `>${MAX_SUBPATHS_COUNT - 1}` : `${size}`;
+       return ` ${num} ${unit}`;
+}
+
+function formatFileSize(size) {
+       if (size == null) return [0, "B"];
+       const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+       if (size == 0) return [0, "B"];
+       const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
+       let ratio = 1;
+       if (i >= 3) {
+               ratio = 100;
+       }
+       return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
+}
+
+function formatDuration(seconds) {
+       seconds = Math.ceil(seconds);
+       const h = Math.floor(seconds / 3600);
+       const m = Math.floor((seconds - h * 3600) / 60);
+       const s = seconds - h * 3600 - m * 60;
+       return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
+}
+
+function formatPercent(percent) {
+       if (percent > 10) {
+               return percent.toFixed(1) + "%";
+       } else {
+               return percent.toFixed(2) + "%";
+       }
+}
+
+function encodedStr(rawStr) {
+       return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
+               return '&#' + i.charCodeAt(0) + ';';
+       });
+}
+
+async function assertResOK(res) {
+       if (!(res.status >= 200 && res.status < 300)) {
+               throw new Error(await res.text() || `Invalid status ${res.status}`);
+       }
+}
+
+function getEncoding(contentType) {
+       const charset = contentType?.split(";")[1];
+       if (/charset/i.test(charset)) {
+               let encoding = charset.split("=")[1];
+               if (encoding) {
+                       return encoding.toLowerCase();
+               }
+       }
+       return 'utf-8';
+}
+
+// Parsing base64 strings with Unicode characters
+function decodeBase64(base64String) {
+       const binString = atob(base64String);
+       const len = binString.length;
+       const bytes = new Uint8Array(len);
+       const arr = new Uint32Array(bytes.buffer, 0, Math.floor(len / 4));
+       let i = 0;
+       for (; i < arr.length; i++) {
+               arr[i] = binString.charCodeAt(i * 4) |
+               (binString.charCodeAt(i * 4 + 1) << 8) |
+               (binString.charCodeAt(i * 4 + 2) << 16) |
+               (binString.charCodeAt(i * 4 + 3) << 24);
+       }
+       for (i = i * 4; i < len; i++) {
+               bytes[i] = binString.charCodeAt(i);
+       }
+       return new TextDecoder().decode(bytes);
+}
diff --git a/build.rs b/build.rs
new file mode 100644 (file)
index 0000000..55598f1
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,8 @@
+use std::process::Command;
+
+fn main() {
+       Command::new("terser").args(
+               &[
+                       "src/js/*.js", "-o", "assets/index.min.js"
+               ]).status().unwrap();
+}
diff --git a/run-dev.sh b/run-dev.sh
new file mode 100755 (executable)
index 0000000..5852270
--- /dev/null
@@ -0,0 +1 @@
+cargo watch -x 'run -- /home/will/Desktop/ -Aa will:test@/:rw' -w src -w assets/index.css -w assets/index.html
diff --git a/src/js/actions.js b/src/js/actions.js
new file mode 100644 (file)
index 0000000..76f87a9
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * Delete path
+ * @param {number} index
+ * @returns
+ */
+async function deletePath(index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       await doDeletePath(file.name, newUrl(file.name), () => {
+               document.getElementById(`addPath${index}`)?.remove();
+               DATA.paths[index] = null;
+               if (!DATA.paths.find(v => !!v)) {
+                       $pathsTable.classList.add("hidden");
+                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
+                       $emptyFolder.classList.remove("hidden");
+               }
+       });
+}
+
+async function doDeletePath(name, url, cb) {
+       if (!confirm(`Delete \`${name}\`?`)) return;
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "DELETE",
+               });
+               await assertResOK(res);
+               cb();
+       } catch (err) {
+               alert(`Cannot delete \`${file.name}\`, ${err.message}`);
+       }
+}
+
+/**
+ * Move path
+ * @param {number} index
+ * @returns
+ */
+async function movePath(index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       const fileUrl = newUrl(file.name);
+       const newFileUrl = await doMovePath(fileUrl);
+       if (newFileUrl) {
+               location.href = newFileUrl.split("/").slice(0, -1).join("/");
+       }
+}
+
+async function doMovePath(fileUrl) {
+       const fileUrlObj = new URL(fileUrl);
+
+       const prefix = DATA.uri_prefix.slice(0, -1);
+
+       const filePath = decodeURIComponent(fileUrlObj.pathname.slice(prefix.length));
+
+       let newPath = prompt("Enter new path", filePath);
+       if (!newPath) return;
+       if (!newPath.startsWith("/")) newPath = "/" + newPath;
+       if (filePath === newPath) return;
+       const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
+
+       try {
+               await checkAuth();
+               const res1 = await fetch(newFileUrl, {
+                       method: "HEAD",
+               });
+               if (res1.status === 200) {
+                       if (!confirm("Override existing file?")) {
+                               return;
+                       }
+               }
+               const res2 = await fetch(fileUrl, {
+                       method: "MOVE",
+                       headers: {
+                               "Destination": newFileUrl,
+                       }
+               });
+               await assertResOK(res2);
+               return newFileUrl;
+       } catch (err) {
+               alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
+       }
+}
+
+async function duplicate (index) {
+       const file = DATA.paths[index];
+       if (!file) return;
+       const fileUrl = newUrl(file.name);
+
+       let fileParts = fileUrl.split("/");
+       let origionalName = fileParts.pop();
+       let lastPart = `Copy%20of%20${origionalName}`;
+
+       let newFile = file;
+       newFile.name = `Copy of ${origionalName}`;
+
+       fileParts.push(lastPart);
+       newFileUrl = fileParts.join("/")
+
+       try {
+               await checkAuth();
+               const res1 = await fetch(newFileUrl, {
+                       method: "HEAD",
+               });
+               if (res1.status === 200) {
+                       if (!confirm("Override existing file?")) {
+                               return;
+                       }
+               }
+               const res2 = await fetch(fileUrl, {
+                       method: "COPY",
+                       headers: {
+                               "Destination": newFileUrl,
+                       }
+               });
+               await assertResOK(res2);
+
+               window.location.reload(); // This is a bad idea! i think ! it does work tho so im not sure
+
+       } catch (err) {
+               alert(`Cannot copy \`${fileUrl}\` to \`${newFileUrl}\`, ${err.message}`);
+       }
+}
+
+/**
+ * Create a folder
+ * @param {string} name
+ */
+async function createFolder(name) {
+       const url = newUrl(name);
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "MKCOL",
+               });
+               await assertResOK(res);
+               location.href = url;
+       } catch (err) {
+               alert(`Cannot create folder \`${name}\`, ${err.message}`);
+       }
+}
+
+async function createFile(name) {
+       const url = newUrl(name);
+       try {
+               await checkAuth();
+               const res = await fetch(url, {
+                       method: "PUT",
+                       body: "",
+               });
+               await assertResOK(res);
+               location.href = url + "?edit";
+       } catch (err) {
+               alert(`Cannot create file \`${name}\`, ${err.message}`);
+       }
+}
diff --git a/src/js/const.js b/src/js/const.js
new file mode 100644 (file)
index 0000000..b508c93
--- /dev/null
@@ -0,0 +1,21 @@
+const IFRAME_FORMATS = [
+       ".pdf",
+".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg",
+".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm",
+".mp3", ".ogg", ".wav", ".m4a",
+];
+
+const MAX_SUBPATHS_COUNT = 1000;
+
+const ICONS = {
+       dir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`,
+       symlinkFile: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`,
+       symlinkDir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`,
+       file: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`,
+       download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
+       move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
+       edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
+       delete: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
+       view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
+       duplicate: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M 3.6269531 0 C 2.6755659 0 1.9003906 0.78091373 1.9003906 1.7324219 L 1.9003906 12.058594 C 1.9003906 13.010102 2.6755661 13.791016 3.6269531 13.791016 L 3.9667969 13.791016 L 3.9667969 14.267578 C 3.9667969 15.219396 4.7423239 16 5.6933594 16 L 12.554688 16 C 13.505722 16 14.283203 15.219396 14.283203 14.267578 L 14.283203 3.9414062 C 14.283203 2.9895887 13.505722 2.2089844 12.554688 2.2089844 L 12.216797 2.2089844 L 12.216797 1.7324219 C 12.216797 0.7800921 11.439668 -1.4802974e-16 10.488281 0 L 3.6269531 0 z M 3.6269531 0.8984375 L 10.488281 0.8984375 C 10.958544 0.8984375 11.318359 1.2583282 11.318359 1.7324219 L 11.318359 2.1503906 L 5.6503906 2.1503906 C 4.6998277 2.1503906 3.9238281 2.9293513 3.9238281 3.8808594 L 3.9238281 12.892578 L 3.6269531 12.892578 C 3.1566901 12.892578 2.7988281 12.533509 2.7988281 12.058594 L 2.7988281 1.7324219 C 2.7988281 1.2575066 3.1566903 0.8984375 3.6269531 0.8984375 z M 5.6933594 3.109375 L 12.554688 3.109375 C 13.025301 3.109375 13.382812 3.4668003 13.382812 3.9414062 L 13.382812 14.267578 C 13.382812 14.742185 13.025301 15.099609 12.554688 15.099609 L 5.6933594 15.099609 C 5.2227448 15.099609 4.8652344 14.742184 4.8652344 14.267578 L 4.8652344 3.9414062 C 4.8652344 3.4668004 5.2227452 3.109375 5.6933594 3.109375 z"/></svg>`
+}
diff --git a/src/js/dragdrop.js b/src/js/dragdrop.js
new file mode 100644 (file)
index 0000000..d2175ba
--- /dev/null
@@ -0,0 +1,111 @@
+function makeDrag ( e ) {
+       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
+               e.addEventListener(name, ev => {
+                       ev.stopPropagation();
+               });
+       });
+
+       e.addEventListener("dragstart", ev => {
+               ev.dataTransfer.effectAllowed = 'move';
+
+               ev.dataTransfer.setData('text/plain', e.dataset.i);
+
+               for (zone of document.getElementsByClassName("internaldrop")) {
+                       zone.classList.add("dragging");
+               }
+
+               console.log("drag start");
+       });
+
+       e.addEventListener("dragend", (ev) => {
+               if (ev.target.classList.contains("dropzone")) {
+                       ev.target.classList.remove("dragover");
+               }
+
+               for (zone of document.getElementsByClassName("internaldrop")) {
+                       zone.classList.remove("dragging");
+                       zone.classList.remove("dragover");
+               }
+       },);
+}
+
+function makeDrop ( e ) {
+       e.addEventListener("dragenter", (ev) => {
+               ev.stopPropagation();
+               if (ev.target.classList.contains("internaldrop")) {
+                       ev.target.classList.add("dragover");
+               }
+       },);
+
+       e.addEventListener("dragleave", (ev) => {
+               ev.stopPropagation();
+               if (ev.target.classList.contains("dragover")) {
+                       ev.target.classList.remove("dragover");
+               }
+       },);
+
+       e.addEventListener("dragover", (ev) => {
+               ev.preventDefault()
+       },);
+
+       let method;
+       if (e.classList.contains("dropcopy")) {
+               method = "COPY";
+       } else {
+               method = "MOVE";
+       }
+       e.addEventListener("drop", getDrop(method, e),);
+}
+
+var getDrop = function ( method, e ) {
+       return async function doDrop (ev) {
+               ev.stopPropagation();
+               const index = ev.dataTransfer.getData("text/plain");
+
+               if (index == e.dataset.i) {return};
+
+               const file = DATA.paths[index];
+               if (!file) return;
+               const fileUrl = newUrl(file.name);
+               const fileName = fileUrl.split("/").at(-1)
+
+               const dir = DATA.paths[e.dataset.i];
+               if (!dir) return;
+               const regex = /\/[^\/]+\/\.\./i;
+               const dirUrl = newUrl(dir.name).replace(regex, "");
+
+               let newFileUrl = `${dirUrl}/${fileName}`;
+
+               try {
+                       await checkAuth();
+                       const res1 = await fetch(newFileUrl, {
+                               method: "HEAD",
+                       });
+                       if (res1.status === 200) {
+                               if (!confirm("Override existing file?")) {
+                                       return;
+                               }
+                       }
+                       const res2 = await fetch(fileUrl, {
+                               method: method,
+                               headers: {
+                                       "Destination": newFileUrl,
+                               }
+                       });
+                       await assertResOK(res2);
+
+                       if (method == "MOVE") {
+                               document.getElementById(`addPath${index}`)?.remove();
+                               DATA.paths[index] = null;
+                               if (!DATA.paths.find(v => !!v)) {
+                                       $pathsTable.classList.add("hidden");
+                                       $emptyFolder.textContent = DIR_EMPTY_NOTE;
+                                       $emptyFolder.classList.remove("hidden");
+                               }
+                       }
+
+               } catch (err) {
+                       alert(`Cannot ${method.toLowerCase()} \`${fileUrl}\` to \`${newFileUrl}\`, ${err.message}`);
+               }
+       }
+}
diff --git a/src/js/helper.js b/src/js/helper.js
new file mode 100644 (file)
index 0000000..13c5ef3
--- /dev/null
@@ -0,0 +1,149 @@
+function newUrl(name) {
+       let url = baseUrl();
+       if (!url.endsWith("/")) url += "/";
+       url += name.split("/").map(encodeURIComponent).join("/");
+       return url;
+}
+
+function baseUrl() {
+       return location.href.split(/[?#]/)[0];
+}
+
+function baseName(url) {
+       return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0]);
+}
+
+function extName(filename) {
+       const dotIndex = filename.lastIndexOf('.');
+
+       if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
+               return '';
+       }
+
+       return filename.substring(dotIndex);
+}
+
+function getPathSvg(path_type) {
+       switch (path_type) {
+               case "Dir":
+                       return ICONS.dir;
+               case "SymlinkFile":
+                       return ICONS.symlinkFile;
+               case "SymlinkDir":
+                       return ICONS.symlinkDir;
+               default:
+                       return ICONS.file;
+       }
+}
+
+function formatMtime(mtime) {
+       if (!mtime) return "";
+       const date = new Date(mtime);
+       const year = date.getFullYear();
+       const month = padZero(date.getMonth() + 1, 2);
+       const day = padZero(date.getDate(), 2);
+       const hours = padZero(date.getHours(), 2);
+       const minutes = padZero(date.getMinutes(), 2);
+       return `${year}-${month}-${day} ${hours}:${minutes}`;
+}
+
+function padZero(value, size) {
+       return ("0".repeat(size) + value).slice(-1 * size);
+}
+
+function formatDirSize(size) {
+       const unit = size === 1 ? "item" : "items";
+       const num = size >= MAX_SUBPATHS_COUNT ? `>${MAX_SUBPATHS_COUNT - 1}` : `${size}`;
+       return ` ${num} ${unit}`;
+}
+
+function formatFileSize(size) {
+       if (size == null) return [0, "B"];
+       const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+       if (size == 0) return [0, "B"];
+       const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
+       let ratio = 1;
+       if (i >= 3) {
+               ratio = 100;
+       }
+       return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
+}
+
+function formatDuration(seconds) {
+       seconds = Math.ceil(seconds);
+       const h = Math.floor(seconds / 3600);
+       const m = Math.floor((seconds - h * 3600) / 60);
+       const s = seconds - h * 3600 - m * 60;
+       return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
+}
+
+function formatPercent(percent) {
+       if (percent > 10) {
+               return percent.toFixed(1) + "%";
+       } else {
+               return percent.toFixed(2) + "%";
+       }
+}
+
+function encodedStr(rawStr) {
+       return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
+               return '&#' + i.charCodeAt(0) + ';';
+       });
+}
+
+async function assertResOK(res) {
+       if (!(res.status >= 200 && res.status < 300)) {
+               throw new Error(await res.text() || `Invalid status ${res.status}`);
+       }
+}
+
+function getEncoding(contentType) {
+       const charset = contentType?.split(";")[1];
+       if (/charset/i.test(charset)) {
+               let encoding = charset.split("=")[1];
+               if (encoding) {
+                       return encoding.toLowerCase();
+               }
+       }
+       return 'utf-8';
+}
+
+// Parsing base64 strings with Unicode characters
+function decodeBase64(base64String) {
+       const binString = atob(base64String);
+       const len = binString.length;
+       const bytes = new Uint8Array(len);
+       const arr = new Uint32Array(bytes.buffer, 0, Math.floor(len / 4));
+       let i = 0;
+       for (; i < arr.length; i++) {
+               arr[i] = binString.charCodeAt(i * 4) |
+               (binString.charCodeAt(i * 4 + 1) << 8) |
+               (binString.charCodeAt(i * 4 + 2) << 16) |
+               (binString.charCodeAt(i * 4 + 3) << 24);
+       }
+       for (i = i * 4; i < len; i++) {
+               bytes[i] = binString.charCodeAt(i);
+       }
+       return new TextDecoder().decode(bytes);
+}
+async function checkAuth() {
+       if (!DATA.auth) return;
+       const res = await fetch(baseUrl(), {
+               method: "CHECKAUTH",
+       });
+       await assertResOK(res);
+       $loginBtn.classList.add("hidden");
+       $logoutBtn.classList.remove("hidden");
+       $userName.textContent = await res.text();
+}
+
+function logout() {
+       if (!DATA.auth) return;
+       const url = baseUrl();
+       const xhr = new XMLHttpRequest();
+       xhr.open("LOGOUT", url, true, DATA.user);
+       xhr.onload = () => {
+               location.href = url;
+       }
+       xhr.send();
+}
diff --git a/src/js/index.js b/src/js/index.js
new file mode 100644 (file)
index 0000000..c4f2d60
--- /dev/null
@@ -0,0 +1,392 @@
+/**
+* @typedef {object} PathItem
+* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
+* @property {string} name
+* @property {number} mtime
+* @property {number} size
+*/
+
+/**
+* @typedef {object} DATA
+* @property {string} href
+* @property {string} uri_prefix
+* @property {"Index" | "Edit" | "View"} kind
+* @property {PathItem[]} paths
+* @property {boolean} allow_upload
+* @property {boolean} allow_delete
+* @property {boolean} allow_search
+* @property {boolean} allow_archive
+* @property {boolean} auth
+* @property {string} user
+* @property {boolean} dir_exists
+* @property {string} editable
+*/
+
+var OZVA_MAX_UPLOADINGS = 1;
+
+/**
+* @type {DATA} DATA
+*/
+var DATA;
+
+/**
+* @type {string}
+*/
+var DIR_EMPTY_NOTE;
+
+/**
+* @type {PARAMS}
+* @typedef {object} PARAMS
+* @property {string} q
+* @property {string} sort
+* @property {string} order
+*/
+const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
+
+/**
+* @type Map<string, Uploader>
+*/
+const failUploaders = new Map();
+
+/**
+* @type Element
+*/
+let $pathsTable;
+/**
+* @type Element
+*/
+let $pathsTableHead;
+/**
+* @type Element
+*/
+let $pathsTableBody;
+/**
+* @type Element
+*/
+let $uploadersTable;
+/**
+* @type Element
+*/
+let $emptyFolder;
+/**
+* @type Element
+*/
+let $editor;
+/**
+* @type Element
+*/
+let $loginBtn;
+/**
+* @type Element
+*/
+let $logoutBtn;
+/**
+* @type Element
+*/
+let $userName;
+
+// Produce table when window loads
+window.addEventListener("DOMContentLoaded", async () => {
+       const $indexData = document.getElementById('index-data');
+       if (!$indexData) {
+               alert("No data");
+               return;
+       }
+
+       DATA = JSON.parse(decodeBase64($indexData.innerHTML));
+       DIR_EMPTY_NOTE = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
+
+       await ready();
+});
+
+async function ready() {
+       $pathsTable = document.querySelector(".paths-table");
+       $pathsTableHead = document.querySelector(".paths-table thead");
+       $pathsTableBody = document.querySelector(".paths-table tbody");
+       $uploadersTable = document.querySelector(".uploaders-table");
+       $emptyFolder = document.querySelector(".empty-folder");
+       $editor = document.querySelector(".editor");
+       $loginBtn = document.querySelector(".login-btn");
+       $logoutBtn = document.querySelector(".logout-btn");
+       $userName = document.querySelector(".user-name");
+
+       addBreadcrumb(DATA.href, DATA.uri_prefix);
+
+       if (DATA.kind === "Index") {
+               document.title = `Index of ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".index-page").classList.remove("hidden");
+
+               await setupIndexPage();
+
+               // make files draggable
+               for (e of document.querySelectorAll('[draggable="true"]')) {
+                       makeDrag(e);
+               }
+
+               for (e of document.getElementsByClassName('internaldrop')) {
+                       makeDrop(e);
+               }
+
+       } else if (DATA.kind === "Edit") {
+               document.title = `Edit ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".editor-page").classList.remove("hidden");
+
+               await setupEditorPage();
+       } else if (DATA.kind === "Rich") {
+               document.title = `Rich Editor ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".rich-page").classList.remove("hidden");
+
+               await setupRichPage();
+       } else if (DATA.kind === "View") {
+               document.title = `View ${DATA.href} - OzVa Cloud`;
+               document.querySelector(".editor-page").classList.remove("hidden");
+
+               await setupEditorPage();
+       }
+}
+
+/**
+* Add breadcrumb
+* @param {string} href
+* @param {string} uri_prefix
+*/
+function addBreadcrumb(href, uri_prefix) {
+       const $breadcrumb = document.querySelector(".breadcrumb");
+       let parts = [];
+       if (href === "/") {
+               parts = [""];
+       } else {
+               parts = href.split("/");
+       }
+       const len = parts.length;
+       let path = uri_prefix;
+       for (let i = 0; i < len; i++) {
+               const name = parts[i];
+               if (i > 0) {
+                       if (!path.endsWith("/")) {
+                               path += "/";
+                       }
+                       path += encodeURIComponent(name);
+               }
+               const encodedName = encodedStr(name);
+               if (i === 0) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}" title="Root"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
+               } else if (i === len - 1) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
+               } else {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}">${encodedName}</a>`);
+               }
+               if (i !== len - 1) {
+                       $breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
+               }
+       }
+}
+
+async function setupIndexPage() {
+       if (DATA.allow_archive) {
+               const $download = document.querySelector(".download");
+               $download.href = baseUrl() + "?zip";
+               $download.title = "Download folder as a .zip file";
+               $download.classList.remove("hidden");
+       }
+
+       if (DATA.allow_upload) {
+               setupDropzone();
+               setupUploadFile();
+               setupNewFolder();
+               setupNewFile();
+       }
+
+       if (DATA.auth) {
+               await setupAuth();
+       }
+
+       if (DATA.allow_search) {
+               setupSearch();
+       }
+
+       setupGridButton();
+       renderPathsTableHead();
+       renderPathsTableBody();
+}
+
+/**
+* Render path table thead
+*/
+function renderPathsTableHead() {
+       const headerItems = [
+               {
+                       name: "name",
+                       props: `colspan="2"`,
+                       text: "Name",
+               },
+               {
+                       name: "mtime",
+                       props: ``,
+                       text: "Last Modified",
+               },
+               {
+                       name: "size",
+                       props: ``,
+                       text: "Size",
+               }
+       ];
+       $pathsTableHead.insertAdjacentHTML("beforeend", `
+               <tr>
+                       ${headerItems.map(item => {
+               let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
+               let order = "desc";
+               if (PARAMS.sort === item.name) {
+                       if (PARAMS.order === "desc") {
+                               order = "asc";
+                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
+                       } else {
+                               svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
+                       }
+               }
+               const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
+               const icon = `<span>${svg}</span>`
+               return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
+       }).join("\n")}
+                       <th class="cell-actions">Actions</th>
+               </tr>
+       `);
+}
+
+/**
+* Render path table tbody
+*/
+function renderPathsTableBody() {
+       if (DATA.paths && DATA.paths.length > 0) {
+               const len = DATA.paths.length;
+               if (len > 0) {
+                       $pathsTable.classList.remove("hidden");
+               }
+               for (let i = 0; i < len; i++) {
+                       addPath(DATA.paths[i], i);
+               }
+       } else {
+               $emptyFolder.textContent = DIR_EMPTY_NOTE;
+               $emptyFolder.classList.remove("hidden");
+       }
+}
+
+/**
+* Add pathitem
+* @param {PathItem} file
+* @param {number} index
+*/
+function addPath(file, index) {
+       const encodedName = encodedStr(file.name);
+       let url = newUrl(file.name);
+       let actionDelete = "";
+       let actionDownload = "";
+       let actionMove = "";
+       let actionEdit = "";
+       let actionView = "";
+       let isDir = file.path_type.endsWith("Dir");
+       let isParent = (file.name == "..");
+       let isImage = (["png", "jpg", "svg"].includes(url.slice(-3).toLowerCase()));
+       let isDocument = (url.slice(-3).toLowerCase() == "pdf");
+       if (isDir) {
+               url += "/";
+               if (DATA.allow_archive) {
+                       actionDownload = `
+                       <div class="action-btn">
+                               <a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
+                       </div>`;
+               }
+       } else {
+               actionDownload = `
+               <div class="action-btn" >
+                       <a href="${url}" title="Download file" download>${ICONS.download}</a>
+               </div>`;
+       }
+       if (DATA.allow_delete) {
+               if (DATA.allow_upload) {
+                       actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
+                       if (!isDir) {
+                               actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
+                       }
+               }
+               actionDelete = `
+               <div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
+       }
+       if (!actionEdit && !isDir) {
+               actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
+       }
+       let actionPreview = "";
+       if (isImage && !isDir) {
+               actionPreview = `<div class="thumbnail-preview"><span>[Preview]</span><img class='thumbnail' loading='lazy' src='${url}' /></div>`;
+       }
+       let actionCell = `
+       <td class="cell-actions">
+               ${actionDownload}
+               ${actionView}
+               ${actionMove}
+               <div onclick="duplicate(${index})" class="action-btn" id="duplicateBtn${index}" title="Duplicate">${ICONS.duplicate}</div>
+               ${actionDelete}
+               ${actionEdit}
+       </td>`;
+
+       let sizeDisplay = isDir ? formatDirSize(file.size) : formatFileSize(file.size).join(" ");
+
+       $pathsTableBody.insertAdjacentHTML("beforeend", `
+<tr id="addPath${index}">
+       <td class="path cell-icon ${isImage ? 'has-preview' : ''}">
+                       ${getPathSvg(file.path_type)}
+       </td>
+       <td class="path cell-name">
+               <div class="drag-div" ${isParent ? "" : `draggable="true"`} data-i="${index}">
+                       <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
+               </div>${actionPreview}
+${isDir ? `
+       <div class="internaldrop dropcopy" data-i="${index}">Copy</div>
+       <div class="internaldrop dropmove" data-i="${index}">Move</div>
+` : ''}
+       </td>
+       <td class="cell-mtime">${formatMtime(file.mtime)}</td>
+       <td class="cell-size">${sizeDisplay}</td>
+       ${actionCell}
+</tr>`);
+}
+
+/**
+* Save editor change
+*/
+async function saveChange() {
+       try {
+               await fetch(baseUrl(), {
+                       method: "PUT",
+                       body: $editor.value,
+               });
+               location.reload();
+       } catch (err) {
+               alert(`Failed to save file, ${err.message}`);
+       }
+}
+
+async function addFileEntries(entries, dirs) {
+       for (const entry of entries) {
+               if (entry == null) {
+                       // this is the case if the user has dragged a file into the file upload area
+                       // this can be made unlikely with fun css
+                       return
+               } else if (entry.isFile) {
+                       entry.file(file => {
+                               new Uploader(file, dirs).upload();
+                       });
+               } else if (entry.isDirectory) {
+                       const dirReader = entry.createReader();
+
+                       const successCallback = entries => {
+                               if (entries.length > 0) {
+                                       addFileEntries(entries, [...dirs, entry.name]);
+                                       dirReader.readEntries(successCallback);
+                               }
+                       };
+
+                       dirReader.readEntries(successCallback);
+               }
+       }
+}
diff --git a/src/js/setup.js b/src/js/setup.js
new file mode 100644 (file)
index 0000000..b3de157
--- /dev/null
@@ -0,0 +1,189 @@
+function setupDropzone() {
+       const dropZone = document.getElementById("filedrop");
+       dropZone.addEventListener("dragenter", e => {
+               e.stopPropagation();
+
+               if (!e.dataTransfer.getData("text/plain")) {
+                       dropZone.classList.add("dragoverfile");
+               }
+       });
+       dropZone.addEventListener("dragleave", e => {
+               e.stopPropagation();
+
+               dropZone.classList.remove("dragoverfile");
+       });
+
+       ["dragend", "dragleave"].forEach(name => {
+               document.body.addEventListener(name, e => {
+                       e.preventDefault();
+                       e.stopPropagation();
+
+                       if ("dragging" in dropZone.classList) {
+                               dropZone.classList.remove("dragging");
+                       }
+               });
+       });
+
+       ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
+               dropZone.addEventListener(name, e => {
+                       e.preventDefault();
+                       e.stopPropagation();
+               });
+       });
+       dropZone.addEventListener("drop", async e => {
+               dropZone.classList.remove("dragging");
+
+               if (!e.dataTransfer.items[0].webkitGetAsEntry) {
+                       const files = Array.from(e.dataTransfer.files).filter(v => v.size > 0);
+                       for (const file of files) {
+                               new Uploader(file, []).upload();
+                       }
+               } else {
+                       const entries = [];
+                       const len = e.dataTransfer.items.length;
+                       for (let i = 0; i < len; i++) {
+                               entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
+                       }
+                       addFileEntries(entries, []);
+               }
+       });
+}
+
+async function setupAuth() {
+       if (DATA.user) {
+               $logoutBtn.classList.remove("hidden");
+               $logoutBtn.addEventListener("click", logout);
+               $userName.textContent = DATA.user;
+       } else {
+               $loginBtn.classList.remove("hidden");
+               $loginBtn.addEventListener("click", async () => {
+                       try {
+                               await checkAuth();
+                       } catch {}
+                       location.reload();
+               });
+       }
+}
+
+function setupSearch() {
+       const $searchbar = document.querySelector(".searchbar");
+       $searchbar.classList.remove("hidden");
+       $searchbar.addEventListener("submit", event => {
+               event.preventDefault();
+               const formData = new FormData($searchbar);
+               const q = formData.get("q");
+               let href = baseUrl();
+               if (q) {
+                       href += "?q=" + q;
+               }
+               location.href = href;
+       });
+       if (PARAMS.q) {
+               document.getElementById('search').value = PARAMS.q;
+       }
+}
+
+function setupUploadFile() {
+       document.querySelector(".upload-file").classList.remove("hidden");
+       document.getElementById("file").addEventListener("change", async e => {
+               const files = e.target.files;
+               for (let file of files) {
+                       new Uploader(file, []).upload();
+               }
+       });
+}
+
+function setupNewFolder() {
+       const $newFolder = document.querySelector(".new-folder");
+       $newFolder.classList.remove("hidden");
+       $newFolder.addEventListener("click", () => {
+               const name = prompt("Enter folder name");
+               if (name) createFolder(name);
+       });
+}
+
+function setupNewFile() {
+       const $newFile = document.querySelector(".new-file");
+       $newFile.classList.remove("hidden");
+       $newFile.addEventListener("click", () => {
+               const name = prompt("Enter file name");
+               if (name) createFile(name);
+       });
+}
+
+function setupGridButton() {
+       const $gridButton = document.querySelector(".grid-btn");
+       $gridButton.classList.remove("hidden");
+       $gridButton.addEventListener("click", () => {
+               const $table = document.querySelector(".paths-table");
+               $table.classList.toggle("grid-mode");
+       });
+}
+
+async function setupEditorPage() {
+       const url = baseUrl();
+
+       const $download = document.querySelector(".download");
+       $download.classList.remove("hidden");
+       $download.href = url;
+
+       if (DATA.kind == "Edit") {
+               const $moveFile = document.querySelector(".move-file");
+               $moveFile.classList.remove("hidden");
+               $moveFile.addEventListener("click", async () => {
+                       const query = location.href.slice(url.length);
+                       const newFileUrl = await doMovePath(url);
+                       if (newFileUrl) {
+                               location.href = newFileUrl + query;
+                       }
+               });
+
+               const $deleteFile = document.querySelector(".delete-file");
+               $deleteFile.classList.remove("hidden");
+               $deleteFile.addEventListener("click", async () => {
+                       const url = baseUrl();
+                       const name = baseName(url);
+                       await doDeletePath(name, url, () => {
+                               location.href = location.href.split("/").slice(0, -1).join("/");
+                       });
+               });
+
+               if (DATA.editable) {
+                       const $saveBtn = document.querySelector(".save-btn");
+                       $saveBtn.classList.remove("hidden");
+                       $saveBtn.addEventListener("click", saveChange);
+               }
+       } else if (DATA.kind == "View") {
+               $editor.readonly = true;
+       }
+
+       if (!DATA.editable) {
+               const $notEditable = document.querySelector(".not-editable");
+               const url = baseUrl();
+               const ext = extName(baseName(url));
+               if (IFRAME_FORMATS.find(v => v === ext)) {
+                       $notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`);
+               } else {
+                       $notEditable.classList.remove("hidden");
+                       $notEditable.textContent = "Cannot edit because file is too large or binary.";
+               }
+               return;
+       }
+
+       $editor.classList.remove("hidden");
+       try {
+               const res = await fetch(baseUrl());
+               await assertResOK(res);
+               const encoding = getEncoding(res.headers.get("content-type"));
+               if (encoding === "utf-8") {
+                       $editor.value = await res.text();
+               } else {
+                       const bytes = await res.arrayBuffer();
+                       const dataView = new DataView(bytes);
+                       const decoder = new TextDecoder(encoding);
+                       $editor.value = decoder.decode(dataView);
+               }
+       } catch (err) {
+               alert(`Failed get file, ${err.message}`);
+       }
+}
diff --git a/src/js/upload.js b/src/js/upload.js
new file mode 100644 (file)
index 0000000..3e1f089
--- /dev/null
@@ -0,0 +1,153 @@
+class Uploader {
+       /**
+        *
+        * @param {File} file
+        * @param {string[]} pathParts
+        */
+       constructor(file, pathParts) {
+               /**
+                * @type Element
+                */
+               this.$uploadStatus = null
+               this.uploaded = 0;
+               this.uploadOffset = 0;
+               this.lastUptime = 0;
+               this.name = [...pathParts, file.name].join("/");
+               this.idx = Uploader.globalIdx++;
+               this.file = file;
+               this.url = newUrl(this.name);
+       }
+
+       upload() {
+               const { idx, name, url } = this;
+               const encodedName = encodedStr(name);
+               $uploadersTable.insertAdjacentHTML("beforeend", `
+               <tr id="upload${idx}" class="uploader">
+               <td class="path cell-icon">
+               ${getPathSvg()}
+               </td>
+               <td class="path cell-name">
+               <a href="${url}">${encodedName}</a>
+               </td>
+               <td class="cell-status upload-status" id="uploadStatus${idx}"></td>
+               </tr>`);
+               $uploadersTable.classList.remove("hidden");
+               $emptyFolder.classList.add("hidden");
+               this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
+               this.$uploadStatus.innerHTML = '-';
+               this.$uploadStatus.addEventListener("click", e => {
+                       const nodeId = e.target.id;
+                       const matches = /^retry(\d+)$/.exec(nodeId);
+                       if (matches) {
+                               const id = parseInt(matches[1]);
+                               let uploader = failUploaders.get(id);
+                               if (uploader) uploader.retry();
+                       }
+               });
+               Uploader.queues.push(this);
+               Uploader.runQueue();
+       }
+
+       ajax() {
+               const { url } = this;
+
+               this.uploaded = 0;
+               this.lastUptime = Date.now();
+
+               const ajax = new XMLHttpRequest();
+               ajax.upload.addEventListener("progress", e => this.progress(e), false);
+               ajax.addEventListener("readystatechange", () => {
+                       if (ajax.readyState === 4) {
+                               if (ajax.status >= 200 && ajax.status < 300) {
+                                       this.complete();
+                               } else {
+                                       if (ajax.status != 0) {
+                                               this.fail(`${ajax.status} ${ajax.statusText}`);
+                                       }
+                               }
+                       }
+               })
+               ajax.addEventListener("error", () => this.fail(), false);
+               ajax.addEventListener("abort", () => this.fail(), false);
+               if (this.uploadOffset > 0) {
+                       ajax.open("PATCH", url);
+                       ajax.setRequestHeader("X-Update-Range", "append");
+                       ajax.send(this.file.slice(this.uploadOffset));
+               } else {
+                       ajax.open("PUT", url);
+                       ajax.send(this.file);
+                       // setTimeout(() => ajax.abort(), 3000);
+               }
+       }
+
+       async retry() {
+               const { url } = this;
+               let res = await fetch(url, {
+                       method: "HEAD",
+               });
+               let uploadOffset = 0;
+               if (res.status == 200) {
+                       let value = res.headers.get("content-length");
+                       uploadOffset = parseInt(value) || 0;
+               }
+               this.uploadOffset = uploadOffset;
+               this.ajax();
+       }
+
+       progress(event) {
+               const now = Date.now();
+               const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
+               const [speedValue, speedUnit] = formatFileSize(speed);
+               const speedText = `${speedValue} ${speedUnit}/s`;
+               const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
+               const duration = formatDuration((event.total - event.loaded) / speed);
+               this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
+               this.uploaded = event.loaded;
+               this.lastUptime = now;
+       }
+
+       complete() {
+               const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
+               $uploadStatusNew.innerHTML = `✓`;
+               this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
+               this.$uploadStatus = null;
+               failUploaders.delete(this.idx);
+               Uploader.runnings--;
+               Uploader.runQueue();
+       }
+
+       fail(reason = "") {
+               this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
+               failUploaders.set(this.idx, this);
+               Uploader.runnings--;
+               Uploader.runQueue();
+       }
+        }
+
+        Uploader.globalIdx = 0;
+
+        Uploader.runnings = 0;
+
+        Uploader.auth = false;
+
+        /**
+         * @type Uploader[]
+         */
+        Uploader.queues = [];
+
+
+        Uploader.runQueue = async () => {
+                if (Uploader.runnings >= OZVA_MAX_UPLOADINGS) return;
+                if (Uploader.queues.length == 0) return;
+                Uploader.runnings++;
+                let uploader = Uploader.queues.shift();
+                if (!Uploader.auth) {
+                        Uploader.auth = true;
+                        try {
+                                await checkAuth();
+                        } catch {
+                                Uploader.auth = false;
+                        }
+                }
+                uploader.ajax();
+        }
index f376dfac3691f28f7e990aa6f916faf4635a4b9b..60edfcdef62f104a691065dd6b91a6fbc7dc8016 100644 (file)
@@ -56,7 +56,7 @@ pub type Response = hyper::Response<BoxBody<Bytes, anyhow::Error>>;
 
 const INDEX_HTML: &str = include_str!("../assets/index.html");
 const INDEX_CSS: &str = include_str!("../assets/index.css");
-const INDEX_JS: &str = include_str!("../assets/index.js");
+const INDEX_JS: &str = include_str!("../assets/index.min.js");
 const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico");
 const INDEX_NAME: &str = "index.html";
 const BUF_SIZE: usize = 65536;