diff options
| author | Nikhil M Warrier <nikhilmwarrier@disroot.org> | 2026-09-05 20:03:28 +0530 |
|---|---|---|
| committer | Nikhil M Warrier <nikhilmwarrier@disroot.org> | 2026-09-05 21:36:20 +0530 |
| commit | 7538b6a88405c5903e14f7fa7832c560f39c7fe5 (patch) | |
| tree | 9f19001bd4342e7677bc84306924915f7cc7a1ad | |
Initial Commit
Add LICENSE
Ignore .DS_Store
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | LICENSE | 22 | ||||
| -rw-r--r-- | assets/favicon.ico | bin | 0 -> 9277 bytes | |||
| -rw-r--r-- | assets/index.css | 525 | ||||
| -rw-r--r-- | assets/index.html | 141 | ||||
| -rw-r--r-- | assets/index.js | 1020 | ||||
| -rw-r--r-- | justfile | 5 |
7 files changed, 1716 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4f742f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dufs +dufs.yml +.DS_STORE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright © 2026 + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE.
\ No newline at end of file diff --git a/assets/favicon.ico b/assets/favicon.ico Binary files differnew file mode 100644 index 0000000..c40c557 --- /dev/null +++ b/assets/favicon.ico diff --git a/assets/index.css b/assets/index.css new file mode 100644 index 0000000..181e333 --- /dev/null +++ b/assets/index.css @@ -0,0 +1,525 @@ +* { + box-sizing: border-box; +} + +:root { + color-scheme: light; + --bg-primary: #fff; + --bg-secondary: #fafafa; + --bg-tertiary: #fff; + --bg-hover: #fafafa; + --text-primary: #24292e; + --text-secondary: #5c5c5c; + --text-tertiary: #586069; + --text-accent: #0366d6; + --svg-primary: #24292e; + --svg-secondary: rgba(3, 47, 98, 0.5); + --svg-tertiary: #24292e; + --border-primary: #ddd; + --border-secondary: #ced4da; +} + +html { + font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; + color: var(--text-primary); +} + +body { + margin: 0; + background-color: var(--bg-primary); +} + +svg { + fill: var(--svg-primary); +} + +.hidden { + display: none !important; +} + +.head { + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 0.6em 1em; + position: sticky; + top: 0; + background-color: var(--bg-tertiary); +} + +.breadcrumb { + font-size: 1.25em; + padding-right: 0.6em; + word-break: break-all; +} + +.breadcrumb>a { + color: var(--text-accent); + text-decoration: none; +} + +.breadcrumb>a:hover { + text-decoration: underline; +} + +/* final breadcrumb */ +.breadcrumb>b { + color: var(--text-primary); +} + +.breadcrumb>.separator { + color: var(--text-tertiary); + padding: 0 0.25em; +} + +.breadcrumb svg { + height: 100%; + fill: var(--svg-secondary); +} + +.toolbox { + display: flex; + margin-right: 10px; +} + +.toolbox>a, +.toolbox>div { + /* vertically align with breadcrumb text */ + height: 1.1rem; +} + +.toolbox .control { + cursor: pointer; + padding-left: 0.25em; +} + +.upload-file input { + display: none; +} + +.upload-file label { + cursor: pointer; +} + +.searchbar { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + background-color: var(--bg-secondary); + transition: all .15s; + border: 1px var(--border-primary) solid; + border-radius: 15px; + margin-bottom: 2px; + padding-left: 0.5rem; +} + +.searchbar #search { + box-sizing: border-box; + width: 100%; + height: 100%; + font-size: 16px; + line-height: 16px; + padding: 1px; + padding-left: 0.5rem; + color: var(--text-primary); + background-color: transparent; + border: none; + outline: none; +} + +.searchbar .icon { + color: var(--svg-tertiary); + padding: 6px 3px 3px 0; + cursor: pointer; +} + +.searchbar svg { + fill: var(--svg-tertiary); +} + +.main { + padding: 0 1em; +} + +.empty-folder { + font-style: italic; +} + +.uploaders-table th { + text-align: left; + font-weight: unset; + color: var(--text-secondary); + white-space: nowrap; +} + +.uploaders-table td { + white-space: nowrap; +} + +.uploaders-table .cell-status { + width: 80px; + padding-left: 0.6em; +} + +.cell-status span { + display: inline-block; +} + +.paths-header-row, +.path-item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 120px 70px 130px; + align-items: center; + column-gap: 1.5em; + padding: 0.4em 0; +} + +.path-item:hover { + background-color: var(--bg-hover); +} + +.paths-header-row { + color: var(--text-secondary); + font-weight: unset; + border-bottom: 1px solid var(--border-primary); +} + +.paths-header-row .h-sort { + color: unset; + text-decoration: none; + white-space: nowrap; +} + +.paths-header-row .h-sort>span { + padding-left: 2px; +} + +.path-item .cell-size { + text-align: right; +} + +.path-item .cell-mtime, +.path-item .cell-size { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.path-item .cell-actions { + display: flex; + align-items: center; +} + +.path-item .cell-more { + display: none; + position: relative; +} + +.path svg, +.path-item .cell-icon svg { + height: 16px; + fill: var(--svg-secondary); + vertical-align: text-top; +} + +.path { + list-style: none; +} + +.path-item .cell-name a { + color: var(--text-accent); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: block; + text-decoration: none; +} + +.path-item .cell-name a:hover { + text-decoration: underline; +} + +.action-btn { + padding-right: 0.3em; + cursor: pointer; +} + +.uploaders-table { + padding: 0.5em 0; +} + +.uploader { + padding-right: 1em; +} + +.editor { + width: 100%; + height: calc(100vh - 5rem); + border: 1px solid var(--border-secondary); + background: var(--bg-primary); + color: var(--text-primary); + outline: none; + padding: 5px; +} + +.toolbox-right { + margin-left: auto; + margin-right: 2em; +} + +.login-btn { + cursor: pointer; +} + +.save-btn { + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.logout-btn { + cursor: pointer; + display: flex; + align-items: center; +} + +.user-name { + padding-left: 3px; +} + +.not-editable { + font-style: italic; +} + +.retry-btn { + cursor: pointer; +} + +@media (min-width: 768px) { + .menu, + .menu-content { + display: contents; + } + + .menu-toggle, + .menu-btn { + display: none; + } +} + +@media (max-width: 767.98px) { + .menu { + position: relative; + margin-left: auto; + } + + .menu-toggle { + display: none; + } + + .menu-btn { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0.2em; + user-select: none; + } + + .menu-content { + display: none; + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 100; + flex-direction: column; + min-width: 250px; + padding: 0.4em; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + } + + .menu-toggle:checked ~ .menu-content { + display: flex; + } + + .menu-content .searchbar { + order: -1; + box-sizing: border-box; + margin: 0 0 0.4em; + padding: 0.3em 0.8em; + background-color: var(--bg-secondary); + border-radius: 8px; + display: flex; + height: fit-content; + align-items: center; + justify-content: center; + } + + .menu-content .toolbox, + .menu-content .toolbox-right { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + margin: 0; + padding: 0.5em 0.3em 0.3em; + } + + .menu-content .toolbox .control, + .menu-content .toolbox-right > .login-btn, + .menu-content .toolbox-right > .logout-btn, + .menu-content .toolbox-right > .save-btn { + display: flex; + align-items: center; + height: auto; + padding: 0.4em 0.5em; + border-radius: 8px; + cursor: pointer; + } + + .menu-content .toolbox .control:hover, + .menu-content .toolbox-right > .login-btn:hover, + .menu-content .toolbox-right > .logout-btn:hover, + .menu-content .toolbox-right > .save-btn:hover { + background-color: var(--bg-hover); + } + + .menu-content .upload-file, + .menu-content .upload-file label { + cursor: pointer; + } + + .menu-content .upload-file.hidden label { + pointer-events: none; + } + + .menu-content .toolbox .control.hidden { + display: flex !important; + opacity: 0.35; + cursor: default; + pointer-events: none; + } + + .menu-content .toolbox .control.hidden:hover { + background-color: transparent; + } + + .menu-content .toolbox-right > .login-btn:not(.hidden)::after { + content: "Log In"; + font-size: 0.95em; + margin-left: 0.4em; + } + + .paths-header-row { + display: none; + } + + .path-item { + display: flex; + align-items: center; + gap: 0.5em; + } + + .path-item .cell-icon { + flex: 0 0 24px; + } + + .path-item .cell-name { + flex: 1; + min-width: 0; + } + + .path-item .cell-mtime, + .path-item .cell-size, + .path-item .cell-actions { + display: none; + } + + .path-item .cell-more { + flex: 0 0 28px; + display: block; + } + + .more-toggle { + display: none; + } + + .more-btn { + display: block; + cursor: pointer; + font-size: 1.4em; + line-height: 1; + text-align: center; + user-select: none; + } + + .more-panel { + display: none; + position: absolute; + right: 0; + top: 100%; + z-index: 100; + flex-direction: column; + gap: 0.5em; + padding: 0.6em; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + min-width: 180px; + white-space: nowrap; + } + + .more-toggle:checked ~ .more-panel { + display: flex; + } + + .more-row { + display: flex; + gap: 0.5em; + align-items: center; + justify-content: space-between; + } + + .more-label { + color: var(--text-secondary); + } + + .more-content { + color: var(--text-primary); + } + + .more-actions { + display: flex; + gap: 0.4em; + align-items: center; + justify-content: flex-start; + padding-top: 0.3em; + border-top: 1px solid var(--border-primary); + } +} + +/* dark theme */ +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --bg-primary: #000; + --bg-secondary: #111; + --bg-tertiary: #111; + --bg-hover: #1a1a1a; + --text-primary: #fff; + --text-secondary: #ccc; + --text-tertiary: #586069; /*Unchanged in both color schemes*/ + --text-accent: #3191ff; + --svg-primary: #fff; + --svg-secondary: #fff; + --svg-tertiary: #fff6; + --border-primary: #fff6; + --border-secondary: #ced4da; /*Unchanged in both color schemes*/ + } +} diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..79487e3 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,141 @@ +<!DOCTYPE html> +<html lang="en-US"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <noscript> + <meta http-equiv="refresh" content="0; url=?noscript"> + </noscript> + <link rel="icon" type="image/x-icon" href="__ASSETS_PREFIX__favicon.ico"> + <link rel="stylesheet" href="__ASSETS_PREFIX__index.css"> +</head> + +<body> + <div class="head"> + <div class="breadcrumb"></div> + <div class="menu"> + <input type="checkbox" id="menu-toggle" class="menu-toggle"> + <label for="menu-toggle" class="menu-btn" title="Menu"> + <svg width="22" height="22" viewBox="0 0 16 16"> + <path fill-rule="evenodd" + d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z" /> + </svg> + </label> + <div class="menu-content"> + <div class="toolbox"> + <a href="" class="control download hidden" title="Download file" 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> + </a> + <div class="control move-file hidden" title="Move & Rename"> + <svg class="icon-move" 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"> + </path> + </svg> + </div> + <div class="control delete-file hidden" title="Delete"> + <svg class="icon-delete" width="16" height="16" fill="currentColor" 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> + </div> + <div class="control upload-file hidden" title="Upload files/folders"> + <label for="file"> + <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 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" /> + </svg> + </label> + <input type="file" id="file" title="Upload files/folders" name="file" multiple> + </div> + <div class="control new-folder hidden" title="New folder"> + <svg width="16" height="16" viewBox="0 0 16 16"> + <path + d="m.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z" /> + <path + d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z" /> + </svg> + </div> + <div class="control new-file hidden" title="New File"> + <svg width="16" height="16" viewBox="0 0 16 16"> + <path + d="M8 6.5a.5.5 0 0 1 .5.5v1.5H10a.5.5 0 0 1 0 1H8.5V11a.5.5 0 0 1-1 0V9.5H6a.5.5 0 0 1 0-1h1.5V7a.5.5 0 0 1 .5-.5z" /> + <path + 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> + <form class="searchbar hidden"> + <div class="icon"> + <svg width="16" height="16" viewBox="0 0 16 16"> + <path + d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" /> + </svg> + </div> + <input id="search" title="Searching for folders or files" name="q" type="text" maxlength="128" autocomplete="off" + tabindex="1"> + <input type="submit" hidden /> + </form> + <div class="toolbox-right"> + <div class="login-btn hidden" title="Login"> + <svg width="16" height="16" viewBox="0 0 16 16"> + <path fill-rule="evenodd" + d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z" /> + <path fill-rule="evenodd" + d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" /> + </svg> + </div> + <div class="logout-btn hidden" title="Logout"> + <svg width="16" height="16" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0z"/> + <path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708z"/> + </svg> + <span class="user-name"></span> + </div> + <div class="save-btn hidden" title="Save file"> + <svg viewBox="0 0 1024 1024" width="24" height="24"> + <path + d="M426.666667 682.666667v42.666666h170.666666v-42.666666h-170.666666z m-42.666667-85.333334h298.666667v128h42.666666V418.133333L605.866667 298.666667H298.666667v426.666666h42.666666v-128h42.666667z m260.266667-384L810.666667 379.733333V810.666667H213.333333V213.333333h430.933334zM341.333333 341.333333h85.333334v170.666667H341.333333V341.333333z" + p-id="8311"></path> + </svg> + </div> + </div> + </div> + </div> + </div> + <div class="main"> + <div class="index-page hidden"> + <div class="empty-folder hidden"></div> + <table class="uploaders-table hidden"> + <thead> + <tr> + <th class="cell-name" colspan="2">Name</th> + <th class="cell-status">Progress</th> + </tr> + </thead> + </table> + <div class="paths-table hidden"> + <div class="paths-header"></div> + <div class="paths-body"></div> + </div> + </div> + <div class="editor-page hidden"> + <div class="not-editable hidden"></div> + <textarea id="editor" class="editor hidden" aria-label="Editor" cols="10"></textarea> + </div> + </div> + <template id="index-data">__INDEX_DATA__</template> + <script src="__ASSETS_PREFIX__index.js"></script> +</body> + +</html> diff --git a/assets/index.js b/assets/index.js new file mode 100644 index 0000000..f95bd6e --- /dev/null +++ b/assets/index.js @@ -0,0 +1,1020 @@ +/** + * @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 DUFS_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="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/></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; + +// manage unload event to prevent leaving with uploads in progress +const beforeUnloadHandler = (event) => { + if (Uploader.queues.length > 0 || Uploader.runnings > 0) { + event.preventDefault(); + event.returnValue = ''; + return ''; // for some browsers + } +}; + +// 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-header"); + $pathsTableBody = document.querySelector(".paths-body"); + $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"); + + window.addEventListener('beforeunload', beforeUnloadHandler); + + document.addEventListener('click', e => { + if (e.target.closest('.menu, .cell-more')) return; + document.querySelectorAll('.menu-toggle, .more-toggle').forEach(cb => cb.checked = false); + }); + + addBreadcrumb(DATA.href, DATA.uri_prefix); + + if (DATA.kind === "Index") { + document.title = `Index of ${DATA.href} - Dufs`; + document.querySelector(".index-page").classList.remove("hidden"); + + await setupIndexPage(); + } else if (DATA.kind === "Edit") { + document.title = `Edit ${DATA.href} - Dufs`; + document.querySelector(".editor-page").classList.remove("hidden"); + + await setupEditorPage(); + } else if (DATA.kind === "View") { + document.title = `View ${DATA.href} - Dufs`; + 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 elapsed = now - this.lastUptime; + if (elapsed < 300) return; // throttle update for safari + const speed = (event.loaded - this.uploaded) / elapsed * 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 style="margin-left: 5px;">${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 >= DUFS_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.add("dlwt"); + $download.classList.remove("hidden"); + } + + if (DATA.allow_upload) { + setupDropzone(); + setupUploadFile(); + setupNewFolder(); + setupNewFile(); + } + + if (DATA.auth) { + await setupAuth(); + } + + if (DATA.allow_search) { + setupSearch(); + } + + renderPathsTableHead(); + renderPathsTableBody(); + + if (DATA.user) { + setupDownloadWithToken(); + } +} + +/** + * Render path table thead + */ +function renderPathsTableHead() { + const headerItems = [ + { + name: "name", + cls: "h-name", + text: "Name", + }, + { + name: "mtime", + cls: "h-mtime", + text: "Last Modified", + }, + { + name: "size", + cls: "h-size", + text: "Size", + } + ]; + $pathsTableHead.insertAdjacentHTML("beforeend", ` + <div class="paths-header-row"> + <span class="h-icon"></span> + ${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 `<a class="h-sort ${item.cls}" href="?${qs}">${item.text}${icon}</a>` + }).join("\n")} + <span class="h-actions">Actions</span> + </div> + `); +} + +/** + * 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"); + if (isDir) { + url += "/"; + if (DATA.allow_archive) { + actionDownload = ` + <div class="action-btn"> + <a class="dlwt" href="${url}?zip" title="Download folder as a .zip file" download>${ICONS.download}</a> + </div>`; + } + } else { + actionDownload = ` + <div class="action-btn" > + <a class="dlwt" 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" title="Move & Rename">${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" 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 actionBtns = ` + ${actionDownload} + ${actionView} + ${actionMove} + ${actionDelete} + ${actionEdit}`; + + const mtimeText = formatMtime(file.mtime); + const sizeText = isDir ? formatDirSize(file.size) : formatFileSize(file.size).join(" "); + + $pathsTableBody.insertAdjacentHTML("beforeend", ` +<div class="path-item" id="addPath${index}"> + <span class="cell-icon"> + ${getPathSvg(file.path_type)} + </span> + <span class="cell-name"> + <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a> + </span> + <span class="cell-mtime">${mtimeText}</span> + <span class="cell-size">${sizeText}</span> + <span class="cell-actions">${actionBtns}</span> + <span class="cell-more"> + <input type="checkbox" id="more${index}" class="more-toggle"> + <label for="more${index}" class="more-btn" title="More">⋯</label> + <div class="more-panel"> + <div class="more-row"><span class="more-label">Modified</span><span class="more-content">${mtimeText}</span></div> + <div class="more-row"><span class="more-label">Size</span><span class="more-content">${sizeText}</span></div> + <div class="more-row more-actions">${actionBtns}</div> + </div> + </span> +</div>`); +} + +function setupDropzone() { + ["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => { + document.addEventListener(name, e => { + e.preventDefault(); + e.stopPropagation(); + }); + }); + document.addEventListener("drop", async e => { + 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("login"); + } catch { } + location.reload(); + }); + } +} + +function setupDownloadWithToken() { + document.querySelectorAll("a.dlwt").forEach(link => { + link.addEventListener("click", async e => { + e.preventDefault(); + try { + const link = e.currentTarget || e.target; + const originalHref = link.getAttribute("href"); + const tokengenUrl = new URL(originalHref); + tokengenUrl.searchParams.set("tokengen", ""); + const res = await fetch(tokengenUrl); + if (!res.ok) throw new Error("Failed to fetch token"); + const token = await res.text(); + const downloadUrl = new URL(originalHref); + downloadUrl.searchParams.set("token", token); + const tempA = document.createElement("a"); + tempA.href = downloadUrl.toString(); + tempA.download = ""; + document.body.appendChild(tempA); + tempA.click(); + document.body.removeChild(tempA); + } catch (err) { + alert(`Failed to download, ${err.message}`); + } + }); + }); +} + +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 to 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(variant) { + if (!DATA.auth) return; + const qs = variant ? `?${variant}` : ""; + const res = await fetch(baseUrl() + qs, { + 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.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))); + const raw = size / Math.pow(1024, i); + let value; + if (i > 0 && raw < 999.95) { + value = Math.round(raw * 10) / 10; + } else { + value = Math.round(raw); + } + return [value, 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/justfile b/justfile new file mode 100644 index 0000000..12948f8 --- /dev/null +++ b/justfile @@ -0,0 +1,5 @@ +run: + ./dufs -c ./dufs.yml + +watch: + find ./assets | entr -r ./dufs -c ./dufs.yml |
