98 Commits

Author SHA1 Message Date
Leo Vasanko
d42f0f7601 Zip download support, streaming. Needs cleanup 2023-11-07 23:30:35 +00:00
Leo Vasanko
4c51029c9f A number of bugfixed on watching, which now works much better. 2023-11-07 20:15:09 +00:00
Leo Vasanko
4de2027959 Fix scaling and mobile layouts 2023-11-07 19:05:21 +00:00
Leo Vasanko
d5e1304c0d Connection status/error messages 2023-11-07 18:01:34 +00:00
Leo Vasanko
54d6ea6332 Implement proper login/logout UI, fix breadcrumbs on file listing. 2023-11-07 16:10:56 +00:00
Leo Vasanko
c695c09ecc Persist document in sessionStorage for fast reloads and offline use. 2023-11-07 14:53:36 +00:00
Leo Vasanko
d36605cd5b Rewritten WS handling, file selections. Minor UI fixes. 2023-11-07 14:48:48 +00:00
Leo Vasanko
fc1fb3ea5d Give full tree updates as root message rather than everything as update messages 2023-11-07 14:47:40 +00:00
Leo Vasanko
32fa005c62 Add summary row on file listing 2023-11-07 02:27:04 +00:00
Leo Vasanko
fabec4dd7e Even faster search 2023-11-07 02:26:50 +00:00
Leo Vasanko
ece64f48be Cleanup, TS errors but it compiles and works... 2023-11-07 01:26:41 +00:00
Leo Vasanko
1f24313d23 Bugfixes on tooltip z-indexing. Search using breadcrumbs in table. Link fixes. 2023-11-07 00:44:55 +00:00
Leo Vasanko
e3af21af91 Rewrite document store to keep all docs: filter and path selection without recreation. Much faster sorting and filtering. 2023-11-06 21:50:29 +00:00
Leo Vasanko
6938740b0f Improved responsive styles. Improved focus changes. 2023-11-06 19:23:21 +00:00
Leo Vasanko
b25d0fc14b Breadcrumbs keep longest path, browsing breadcrumbs with left/right arrows, highlight current. 2023-11-06 18:51:51 +00:00
Leo Vasanko
5386508e28 Improved mobile layout for landscape. Oneline header. 2023-11-06 16:51:10 +00:00
Leo Vasanko
129250e072 Implement tooltips and other UI tuning. 2023-11-06 15:33:43 +00:00
Leo Vasanko
c2be2ecd31 Fix copying of files. 2023-11-06 15:32:58 +00:00
Leo Vasanko
dd1d85f412 dateformat code cleanup 2023-11-06 11:38:11 +00:00
Leo Vasanko
4c7b310f82 Added time element on mtimes, dates smaller, code formatted 2023-11-05 23:12:42 +00:00
Leo Vasanko
1250037cfd Always the same date formatting by fixing a suitable locale 2023-11-05 17:14:10 +00:00
Leo Vasanko
cdc936d2d5 Fix bug when browsing around the end 2023-11-05 16:49:24 +00:00
Leo Vasanko
4f370440d9 Shift-Up/Down selection 2023-11-05 16:07:28 +00:00
Leo Vasanko
feaa8e315e Tighter table 2023-11-05 15:55:23 +00:00
Leo Vasanko
14f7253ece Rename FUID field to key everywhere. 2023-11-05 15:54:55 +00:00
Leo Vasanko
9d3d27faf3 Remove generated file from repo 2023-11-05 15:36:37 +00:00
Leo Vasanko
dd235e8f25 Fix clicking files 2023-11-05 14:15:48 +00:00
Leo Vasanko
139ff51dcd Remove build (wwwroot) from repo. UI tweaks. 2023-11-05 14:07:07 +00:00
Leo Vasanko
589e5a682c Smoother UI and various other adjustments. 2023-11-05 13:13:32 +00:00
Leo Vasanko
8114d679ef Merged checkboxes and file icons. 2023-11-05 04:11:24 +00:00
Leo Vasanko
32b8e0702c Fix file deletion, now erases folders and files. 2023-11-05 02:19:46 +00:00
Leo Vasanko
cc74912bb9 Print CSS 2023-11-05 01:31:18 +00:00
Leo Vasanko
c3cf4caa9a Create new folder automatically navigates into it. Rename still flaky. 2023-11-04 21:41:44 +00:00
Leo Vasanko
b3eacf04f7 Fix selection button visibility 2023-11-04 21:04:29 +00:00
Leo Vasanko
047facaacb Style tuning 2023-11-04 20:54:14 +00:00
Leo Vasanko
41fbd3d122 Multiple file downloads via Filesystem API, and other tuning. 2023-11-04 19:50:05 +00:00
Leo Vasanko
40a45568c1 Remove width and height attributes of SVGs to make them scalable via CSS 2023-11-04 14:29:49 +00:00
Leo Vasanko
8c6690ea98 Major changes:
- File selections working
- CSS more responsive, more consistent use of colors and variables
- Keyboard navigation
- Added context menu buttons and handler, the menu is still missing
- Added download and settings buttons (no functions yet)
- Various minor fixes everywhere
2023-11-04 14:10:18 +00:00
Leo Vasanko
997e0b8549 Numeric sorting and filter. "New Folder 12" > "New Folder 2" 2023-11-04 01:27:09 +00:00
Leo Vasanko
115bb5db59 Restore quick search functionality. 2023-11-04 00:57:54 +00:00
Leo Vasanko
5f1eb0503a ... 2023-11-04 00:44:13 +00:00
Leo Vasanko
4aae194060 Remove extra new folder button, instead make header button work 2023-11-04 00:43:37 +00:00
Leo Vasanko
12eabd29c3 Recreated page navigation buttons. 2023-11-04 00:21:35 +00:00
Leo Vasanko
589b21f944 Don't need to import components anymore 2023-11-03 22:26:30 +00:00
Leo Vasanko
d3f584b738 URL decode before using control API 2023-11-03 21:59:44 +00:00
Leo Vasanko
225f2b0651 A bit hacky but working mkdir 2023-11-03 21:54:11 +00:00
Leo Vasanko
b759d8324c Login still a bit buggy but working... 2023-11-03 21:19:26 +00:00
Leo Vasanko
119aba2b3c Cleanup, lint, format etc. 2023-11-03 20:07:05 +00:00
Leo Vasanko
f52d58d645 Big changes...
- Added Droppy SVG icons
- Implemented Droppy-style Breadcrumb component
- Implemented a Dialog component
- Attempted transition effects on file explorer (not yet functional)
- Changed FileExplorer to take list of documents and current path via props.
- Various other cleanup etc.
2023-11-03 16:19:21 +00:00
Leo Vasanko
6cba674b30 Build down to 90 kB, 1.5 MB less after removing ant-design. 2023-11-02 22:34:57 +00:00
Leo Vasanko
831b2716f7 Removing stuff, refactoring for simplicity 2023-11-02 21:52:21 +00:00
Leo Vasanko
7e5901a2cf Fix indent 2023-11-02 18:51:34 +00:00
Leo Vasanko
a4f95d730b Fix file links 2023-11-02 17:58:36 +00:00
Leo Vasanko
56082cba15 Faster animation to match the fast update 2023-11-02 17:56:55 +00:00
Leo Vasanko
3479a0da57 Remove node_modules 2023-11-02 17:40:25 +00:00
Leo Vasanko
f99d92b217 Add support for actual renaming of files, and UI on plain tree. 2023-11-02 17:37:28 +00:00
Leo Vasanko
68a701538b Prototyping plain table for files list 2023-11-02 15:34:37 +00:00
Leo Vasanko
05a16e3037 New build, typing ignores... 2023-11-01 23:07:13 +00:00
Leo Vasanko
52ecbc3d36 Search filtering 2023-11-01 23:00:59 +00:00
Leo Vasanko
042f1b7f42 cista.util submodule 2023-11-01 21:29:34 +00:00
Leo Vasanko
d27cb2133a Rewrite folder selection. Needs better error handling still. 2023-11-01 21:29:13 +00:00
Leo Vasanko
a8ea43194d Add settings to use npm run dev server with cista -l :8000 backend 2023-11-01 21:12:47 +00:00
Leo Vasanko
07fe7448cc Update front build. 2023-11-01 20:16:37 +00:00
Leo Vasanko
783af44e26 Ruff 2023-11-01 19:58:59 +00:00
Leo Vasanko
0d6180e8a4 Add charset=UTF-8 2023-11-01 17:32:48 +00:00
Leo Vasanko
bdc0bbd44f Implemented HTTP caching and updates on wwwroot, much faster page loads. 2023-11-01 17:08:05 +00:00
Leo Vasanko
ba36eaec1b Allow multiple commands on control socket without disconnecting. 2023-11-01 14:57:54 +00:00
Leo Vasanko
a435a30c88 Realtime updates of wwwroot files when --dev is used. 2023-11-01 14:53:57 +00:00
Leo Vasanko
742b05ed66 Faster wwwroot serving, uses RAM cache of brotli compressed data for all assets. 2023-11-01 14:40:08 +00:00
Leo Vasanko
a26dc42d88 Fix control message decoding. 2023-11-01 14:12:06 +00:00
Leo Vasanko
9002afbc7e Merged something 2023-11-01 14:03:17 +00:00
Leo Vasanko
acdd776b92 Formatting and fix Internal Server Error on upload 2023-10-28 20:20:34 +00:00
b3fd9637eb Login, Download and visuals update 2023-10-28 04:13:01 -05:00
2b72508206 Search Bar UI update 2023-10-27 15:32:21 +00:00
Leo Vasanko
8cc3ed1a04 Human-readable sizes 2023-10-27 08:28:03 +03:00
Leo Vasanko
0d186726b5 Prettier file listing, using browser instead of viewer for file display (for now), sorting improved, modified timestamps improved. 2023-10-27 07:53:30 +03:00
Leo Vasanko
63bbe84859 Provide file/dir id from server. The value will stay the same if the file is renamed/moved, but will change if a file is replaced by another with the same name. 2023-10-27 07:51:51 +03:00
Leo Vasanko
202f28ff15 Add esbuild dependency 2023-10-27 04:34:27 +03:00
41772e6c18 Fix get files #build_commit 2023-10-26 10:01:54 -05:00
e52379d515 Fix get files #build_commit 2023-10-26 09:48:12 -05:00
74987898c9 Fix get files #build_commit 2023-10-26 09:34:24 -05:00
859d312913 Fix get files 2023-10-26 09:14:24 -05:00
4bc9cf4534 Update endpoints and URL bases configuration 2023-10-26 09:06:07 -05:00
754d779069 Test frontend #only_compile 2023-10-26 00:10:52 -05:00
367e4ba0ea Test frontend #only_compile 2023-10-26 00:08:06 -05:00
c2e9a4af05 Test frontend #only_compile 2023-10-26 00:06:45 -05:00
6cdc37a172 Test frontend #only_compile 2023-10-26 00:05:11 -05:00
19699564c2 Test frontend #only_compile 2023-10-26 00:02:45 -05:00
7baf8b3f9b Test frontend #only_compile 2023-10-25 23:55:50 -05:00
47329ac04e Test frontend #only_compile 2023-10-25 23:46:21 -05:00
f4013d1196 Test frontend #only_compile 2023-10-25 23:34:20 -05:00
3672156b5e Test frontend #only_compile 2023-10-25 23:31:13 -05:00
f2b37852da Test frontend #only_compile 2023-10-25 21:56:45 -05:00
708e54d080 Test frontend #only_compile 2023-10-25 21:52:08 -05:00
d051265f40 Test frontend #only_compile 2023-10-25 21:47:45 -05:00
5cf133465e Test frontend #only_compile 2023-10-25 21:40:44 -05:00
1c91bf2e87 Test frontend #only_compile 2023-10-25 21:26:37 -05:00
9cd6f83bec Initialize VUE project with WS connection, main pages, and various small features 2023-10-25 18:43:44 -05:00
131 changed files with 1737 additions and 3355 deletions

143
README.md
View File

@@ -1,118 +1,25 @@
# Cista Web Storage
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
## Getting Started
### Installation
To install the cista application, use:
```fish
pip install cista
```
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
### Running the Server
Create an account: (or run a public server without authentication)
```fish
cista --user yourname --privileged
```
Serve your files at http://localhost:8000:
```fish
cista -l :8000 /path/to/files
```
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
### Internet Access
To use your own TLS certificates, place them in the config folder and run:
```fish
cista -l cista.example.com
```
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
`/etc/caddy/Caddyfile`:
```Caddyfile
cista.example.com {
reverse_proxy :8000
}
```
## Development setup
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
```fish
cd frontend
npm install
npm run dev
```
Concurrently, start the backend on another terminal:
```fish
hatch shell
pip install -e '.[dev]'
cista --dev -l :8000 /path/to/files
```
We use `hatch shell` for installing on a virtual environment, to avoid disturbing the rest of the system with our hacking.
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
## System Deployment
This setup allows easy addition of storages, each with its own domain, configuration, and files.
Assuming a restricted user account `storage` for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
Create `/etc/systemd/system/cista@.service`:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always
[Install]
WantedBy=multi-user.target
```
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
```fish
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Public exposure is easiest using the Caddy web server.
`/etc/caddy/Caddyfile`:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```
# Web File Storage
Run directly from repository with Hatch (or use pip install as usual):
```sh
hatch run cista -l :3000 /path/to/files
```
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
Create your user account:
```sh
hatch run cista --user admin --privileged
```
## Build frontend
Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
```sh
cd cista-front
npm install
npm run build
```
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.

View File

@@ -1,46 +1,40 @@
# Cista Vue Frontend
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Hot-Reload for Development
### Run the backend
```fish
hatch shell
cista --dev -l :8000
```
### And the Vite server (in another terminal)
```fish
cd frontend
npm install
npm run dev
```
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
### Type-Check, Compile and Minify for Production
This is also called by `hatch build` during Python packaging:
```fish
npm run build
```
# cista-front
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

View File

@@ -1,11 +1,12 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=UTF-8>
<title>Cista Storage</title>
<title>Cista</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="/src/assets/logo.svg">
<link rel="icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main.ts"></script>
<body id="app">
<div id="app"></div>

View File

@@ -1,5 +1,5 @@
{
"name": "cista-frontend",
"name": "front",
"version": "0.0.0",
"private": true,
"scripts": {
@@ -12,9 +12,6 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@imengyu/vue3-context-menu": "^1.3.3",
"@vueuse/core": "^10.4.1",
@@ -24,6 +21,7 @@
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"unplugin-vue-components": "^0.25.2",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

241
cista-front/public/old-index.html Executable file
View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<title>Storage</title>
<style>
body {
font-family: sans-serif;
max-width: 100ch;
margin: 0 auto;
padding: 1em;
background-color: #333;
color: #eee;
}
td {
text-align: right;
padding: .5em;
}
td:first-child {
text-align: left;
}
a {
color: inherit;
text-decoration: none;
}
</style>
<div>
<h2>Quick file upload</h2>
<p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p>
<input type=file id=fileInput>
<progress id=progressBar value=0 max=1></progress>
</div>
<div>
<h2>Files</h2>
<ul id=file_list></ul>
</div>
<script>
let files = {}
let flatfiles = {}
function createWatchSocket() {
const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws'))
const ws = new WebSocket(wsurl)
ws.onmessage = event => {
msg = JSON.parse(event.data)
if (msg.update) {
tree_update(msg.update)
file_list(files)
} else {
console.log("Unkonwn message from watch socket", msg)
}
}
}
createWatchSocket()
function tree_update(msg) {
console.log("Tree update", msg)
let node = files
for (const elem of msg) {
if (elem.deleted) {
const p = node.dir[elem.name].path
delete node.dir[elem.name]
delete flatfiles[p]
break
}
if (elem.name !== undefined) node = node.dir[elem.name] ||= {}
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
// Update paths and flatfiles
files.path = "/"
const nodes = [files]
flatfiles = {}
while (node = nodes.pop()) {
flatfiles[node.path] = node
if (node.dir === undefined) continue
for (const name of Object.keys(node.dir)) {
const child = node.dir[name]
child.path = node.path + name + (child.dir === undefined ? "" : "/")
nodes.push(child)
}
}
}
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
const compare_path = (a, b) => collator.compare(a.path, b.path)
const compare_time = (a, b) => a.mtime > b.mtime
function file_list(files) {
const table = document.getElementById("file_list")
const sorted = Object.values(flatfiles).sort(compare_time)
table.innerHTML = ""
for (const f of sorted) {
const {path, size, mtime} = f
const tr = document.createElement("tr")
const name_td = document.createElement("td")
const size_td = document.createElement("td")
const mtime_td = document.createElement("td")
const a = document.createElement("a")
table.appendChild(tr)
tr.appendChild(name_td)
tr.appendChild(size_td)
tr.appendChild(mtime_td)
name_td.appendChild(a)
size_td.textContent = size
mtime_td.textContent = formatUnixDate(mtime)
a.textContent = path
a.href = `/files${path}`
/*a.onclick = event => {
if (window.showSaveFilePicker) {
event.preventDefault()
download_ws(name, size)
}
}
a.download = ""*/
}
}
function formatUnixDate(t) {
const date = new Date(t * 1000)
const now = new Date()
const diff = date - now
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (Math.abs(diff) <= 60000) {
return formatter.format(Math.round(diff / 1000), 'second')
}
if (Math.abs(diff) <= 3600000) {
return formatter.format(Math.round(diff / 60000), 'minute')
}
if (Math.abs(diff) <= 86400000) {
return formatter.format(Math.round(diff / 3600000), 'hour')
}
if (Math.abs(diff) <= 604800000) {
return formatter.format(Math.round(diff / 86400000), 'day')
}
return date.toLocaleDateString()
}
async function download_ws(name, size) {
const fh = await window.showSaveFilePicker({
suggestedName: name,
})
const writer = await fh.createWritable()
writer.truncate(size)
const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws'))
const ws = new WebSocket(wsurl)
let pos = 0
ws.onopen = () => {
console.log("Downloading over WebSocket", name, size)
ws.send(JSON.stringify({name, start: 0, end: size, size}))
}
ws.onmessage = event => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data)
console.log("Download finished", msg)
ws.close()
return
}
console.log("Received chunk", name, pos, pos + event.data.size)
pos += event.data.size
writer.write(event.data)
}
ws.onclose = () => {
if (pos < size) {
console.log("Download aborted", name, pos)
writer.truncate(pos)
}
writer.close()
}
}
const fileInput = document.getElementById("fileInput")
const progress = document.getElementById("progressBar")
const numConnections = 2
const chunkSize = 1<<20
const wsConnections = new Set()
//for (let i = 0; i < numConnections; i++) createUploadWS()
function createUploadWS() {
const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws'))
const ws = new WebSocket(wsurl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
wsConnections.add(ws)
console.log("Upload socket connected")
}
ws.onmessage = event => {
msg = JSON.parse(event.data)
if (msg.written) progress.value += +msg.written
else console.log(`Error: ${msg.error}`)
}
ws.onclose = () => {
wsConnections.delete(ws)
console.log("Upload socket disconnected, reconnecting...")
setTimeout(createUploadWS, 1000)
}
}
async function load(file, start, end) {
const reader = new FileReader()
const load = new Promise(resolve => reader.onload = resolve)
reader.readAsArrayBuffer(file.slice(start, end))
const event = await load
return event.target.result
}
async function sendChunk(file, start, end, ws) {
const chunk = await load(file, start, end)
ws.send(JSON.stringify({
name: file.name,
size: file.size,
start: start,
end: end
}))
ws.send(chunk)
}
fileInput.addEventListener("change", async function() {
const file = this.files[0]
const numChunks = Math.ceil(file.size / chunkSize)
progress.value = 0
progress.max = file.size
console.log(wsConnections)
for (let i = 0; i < numChunks; i++) {
const ws = Array.from(wsConnections)[i % wsConnections.size]
const start = i * chunkSize
const end = Math.min(file.size, start + chunkSize)
const res = await sendChunk(file, start, end, ws)
}
})
</script>

121
cista-front/src/App.vue Normal file
View File

@@ -0,0 +1,121 @@
<template>
<LoginModal />
<header>
<HeaderMain ref="headerMain">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" tabindex="-1"/>
</header>
<main>
<RouterView :path="path.pathList" />
</main>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { watchConnect, watchDisconnect } from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import Router from '@/router/index'
interface Path {
path: string
pathList: string[]
}
const documentStore = useDocumentStore()
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path)
const pathList = p.split('/').filter(value => value !== '')
return {
path: p,
pathList
}
})
onMounted(watchConnect)
onUnmounted(watchDisconnect)
// Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000)
const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = documentStore.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const keyup = event.type === 'keyup'
if (event.repeat) {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
(c && event.code === 'Space')
) {
event.preventDefault()
}
return
}
//console.log("key pressed", event)
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Select all (toggle); keydown to prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
else if (
c &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
) {
fileExplorer.toggleSortColumn(+event.key)
}
// Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
fileExplorer.cursorRename()
}
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
} else return
event.preventDefault()
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
if (!timer) {
// Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
}
}
onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler)
window.addEventListener('keyup', globalShortcutHandler)
})
onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler)
window.removeEventListener('keyup', globalShortcutHandler)
})
export type { Path }
</script>

View File

@@ -14,7 +14,7 @@
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: 4rem;
--header-height: calc(6.5 * var(--header-font-size));
}
@media (prefers-color-scheme: dark) {
:root {
@@ -37,6 +37,12 @@
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
@@ -48,7 +54,6 @@
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
@@ -73,13 +78,17 @@
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
}
@media print {
@@ -89,11 +98,10 @@
--header-background: none;
--header-color: black;
}
.headermain,
nav,
.menu,
.rename-button,
.suggest-gallery {
display: none !important;
.rename-button {
display: none;
}
.breadcrumb > a {
color: black !important;
@@ -108,31 +116,16 @@
}
.breadcrumb svg {
fill: black !important;
margin: 0 .5rem 0 1rem !important;
}
body#app {
height: auto !important;
}
main {
height: auto !important;
padding-bottom: 0 !important;
}
thead tr {
font-size: 1rem !important;
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
}
audio::-webkit-media-controls-timeline,
video::-webkit-media-controls-timeline {
display: none;
}
audio::-webkit-media-controls,
video::-webkit-media-controls {
display: none;
}
tr, figure {
page-break-inside: avoid;
}
.selection {
min-width: 0 !important;
padding: 0 !important;
@@ -149,13 +142,14 @@
left: 0;
}
}
* {
box-sizing: border-box;
}
html {
font-size: var(--root-font-size);
overflow: hidden;
}
/* Hide scrollbar for all browsers */
main::-webkit-scrollbar {
display: none;
}
main {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@@ -172,7 +166,6 @@ tbody .modified {
font-family: 'Roboto Mono';
}
header {
flex: 0 0 auto;
background-color: var(--header-background);
color: var(--header-color);
font-size: var(--header-font-size);
@@ -214,23 +207,21 @@ table {
border: 0;
gap: 0;
}
body#app {
height: 100vh;
#app {
height: 100%;
display: flex;
flex-direction: column;
}
main {
flex: 1 1 auto;
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
text-align: center;
}
header nav.headermain {
/* Position so that tooltips can appear on top of other positioned elements */
position: relative;
z-index: 100;
}
main {
height: calc(100svh - var(--header-height));
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 388 B

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 126 B

After

Width:  |  Height:  |  Size: 126 B

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

View File

Before

Width:  |  Height:  |  Size: 563 B

After

Width:  |  Height:  |  Size: 563 B

View File

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 212 B

View File

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 293 B

View File

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

View File

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 193 B

View File

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

View File

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 711 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 783 B

After

Width:  |  Height:  |  Size: 783 B

View File

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 382 B

View File

Before

Width:  |  Height:  |  Size: 200 B

After

Width:  |  Height:  |  Size: 200 B

View File

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 698 B

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 416 B

View File

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 517 B

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

View File

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 312 B

View File

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 109 B

View File

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

Before

Width:  |  Height:  |  Size: 106 B

After

Width:  |  Height:  |  Size: 106 B

View File

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 393 B

View File

Before

Width:  |  Height:  |  Size: 94 B

After

Width:  |  Height:  |  Size: 94 B

View File

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

View File

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 108 B

View File

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 407 B

View File

Before

Width:  |  Height:  |  Size: 887 B

After

Width:  |  Height:  |  Size: 887 B

View File

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 908 B

View File

Before

Width:  |  Height:  |  Size: 417 B

After

Width:  |  Height:  |  Size: 417 B

View File

Before

Width:  |  Height:  |  Size: 554 B

After

Width:  |  Height:  |  Size: 554 B

View File

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

View File

Before

Width:  |  Height:  |  Size: 114 B

After

Width:  |  Height:  |  Size: 114 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 91 B

After

Width:  |  Height:  |  Size: 91 B

View File

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 647 B

View File

Before

Width:  |  Height:  |  Size: 95 B

After

Width:  |  Height:  |  Size: 95 B

View File

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

View File

Before

Width:  |  Height:  |  Size: 104 B

After

Width:  |  Height:  |  Size: 104 B

View File

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 508 B

View File

Before

Width:  |  Height:  |  Size: 1009 B

After

Width:  |  Height:  |  Size: 1009 B

View File

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

View File

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 753 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 542 B

After

Width:  |  Height:  |  Size: 542 B

View File

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View File

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 621 B

View File

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 517 B

View File

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 289 B

View File

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 498 B

View File

Before

Width:  |  Height:  |  Size: 464 B

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -2,19 +2,14 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@focus="move(0)"
>
<a href="#/"
:ref="el => setLinkRef(0, el)"
class="home"
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
title="/"
>
<component :is="home" />
</a>
@@ -24,7 +19,6 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`"
>{{ location }}</a>
</template>
</nav>
@@ -32,9 +26,8 @@
<script setup lang="ts">
import home from '@/assets/svg/home.svg'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
const router = useRouter()
@@ -44,33 +37,17 @@ onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const props = defineProps<{
path: Array<string>
primary?: boolean
}>()
const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const focusCurrent = () => {
nextTick(() => {
const index = props.path.length
if (index < links.length) links[index].focus()
})
}
const navigate = (index: number) => {
const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
// Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
else router.push(u)
link.focus()
router.replace(`/${longest.value.slice(0, index).join('/')}`)
}
const move = (dir: number) => {
@@ -82,25 +59,13 @@ const move = (dir: number) => {
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
else {
// Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i)
break
}
}
}
// If needed, focus primary navigation to new location
if (props.primary) nextTick(() => {
const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent()
})
})
watchEffect(() => {
if (links.length) navigate(props.path.length)
})
</script>
@@ -115,36 +80,31 @@ watchEffect(() => {
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
flex: 1 1 auto;
display: flex;
min-width: 20%;
max-width: 100%;
min-height: 2em;
list-style: none;
margin: 0;
padding: 0 1em 0 0;
overflow: hidden;
}
.breadcrumb > a {
flex: 0 4 auto;
display: flex;
align-items: center;
margin: 0 -0.5em 0 -0.5em;
padding: 0;
max-width: 8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.5em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb > a:first-child {
flex: 0 0 auto;
padding-left: 1.5em;
padding-right: 1.7em;
.breadcrumb a:first-child {
margin-left: 0;
padding-left: .2em;
clip-path: none;
}
.breadcrumb > a:last-child {
.breadcrumb a:last-child {
max-width: none;
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -155,7 +115,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb > a:only-child {
.breadcrumb a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -167,9 +127,9 @@ watchEffect(() => {
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
padding-left: 0.8em;
width: 1.3em;
height: 1.3em;
margin: -.5em;
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
@@ -189,6 +149,6 @@ watchEffect(() => {
}
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
.breadcrumb a.current { color: var(--accent-color) }
.breadcrumb a.current svg { fill: var(--accent-color) }
</style>

View File

@@ -3,11 +3,34 @@
<thead>
<tr>
<th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
<input
type="checkbox"
tabindex="-1"
v-model="allSelected"
:indeterminate="selectionIndeterminate"
/>
</th>
<th
class="sortcolumn"
:class="{ sortactive: sort === 'name' }"
@click="toggleSort('name')"
>
Name
</th>
<th
class="sortcolumn modified right"
:class="{ sortactive: sort === 'modified' }"
@click="toggleSort('modified')"
>
Modified
</th>
<th
class="sortcolumn size right"
:class="{ sortactive: sort === 'size' }"
@click="toggleSort('size')"
>
Size
</th>
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th>
</tr>
</thead>
@@ -15,50 +38,94 @@
<tr v-if="editing?.key === 'new'" class="folder">
<td class="selection"></td>
<td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
<FileRenameInput
:doc="editing"
:rename="mkdir"
:exit="
() => {
editing = null
}
"
/>
</td>
<FileModified :doc=editing :key=nowkey />
<FileSize :doc=editing />
<td class="modified right">
<time :datetime="new Date(editing.mtime).toISOString().replace('.000', '')">{{
editing.modified
}}</time>
</td>
<td class="size right">{{ editing.sizedisp }}</td>
<td class="menu"></td>
</tr>
<template v-for="(doc, index) in documents" :key="doc.key">
<template
v-for="(doc, index) in sortedDocuments"
:key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr>
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<input
type="checkbox"
tabindex="-1"
:checked="store.selected.has(doc.key)"
:checked="documentStore.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
? documentStore.selected.add(doc.key)
: documentStore.selected.delete(doc.key)
"
/>
</td>
<td class="name">
<template v-if="editing === doc">
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-if="editing === doc"
><FileRenameInput
:doc="doc"
:rename="rename"
:exit="
() => {
editing = null
}
"
/></template>
<template v-else>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
<a
:href="url_for(doc)"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@blur="ev => { if (!editing) cursor = null }"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
>
🖊
</button>
</template>
</td>
<FileModified :doc=doc :key=nowkey />
<FileSize :doc=doc />
<td class="modified right">
<time
:data-tooltip="new Date(1000 * doc.mtime).toISOString().replace('T', '\n').replace('.000Z', ' UTC')"
>{{ doc.modified }}</time
>
</td>
<td class="size right">{{ doc.sizedisp }}</td>
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
<button
tabindex="-1"
@click.stop="contextMenu($event, doc)"
>
</button>
</td>
</tr>
</template>
@@ -69,27 +136,35 @@
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { ref, computed, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
import { formatSize } from '@/utils'
import { collator, formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
const props = defineProps<{
path: Array<string>
documents: Doc[]
}>()
const store = useMainStore()
const props = withDefaults(
defineProps<{
path: Array<string>
documents: Document[]
}>(),
{}
)
const documentStore = useDocumentStore()
const router = useRouter()
const url_for = (doc: Document) => {
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.dir ? `#/${p}/` : `/files/${p}`
}
const cursor = ref<Document | null>(null)
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
@@ -113,71 +188,75 @@ const rename = (doc: Doc, newName: string) => {
}
doc.name = newName // We should get an update from watch but this is quicker
}
const sortedDocuments = computed(() => sorted(props.documents as Document[]))
const showFolderBreadcrumb = (i: number) => {
const docs = sortedDocuments.value
const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
defineExpose({
newFolder() {
console.log("New folder")
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
const now = Date.now() / 1000
editing.value = {
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
type: 'folder',
mtime: now,
size: 0,
})
store.cursor = editing.value.key
sizedisp: formatSize(0),
modified: formatUnixDate(now),
haystack: '',
}
console.log("New")
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', '']
toggleSort(columns[column])
},
isCursor() {
return store.cursor && editing.value === null
return cursor.value !== null && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
editing.value = cursor.value
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
} else {
store.selected.add(key)
documentStore.selected.add(doc.key)
}
this.cursorMove(1, null)
this.cursorMove(1)
},
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
const documents = sortedDocuments.value
if (documents.length === 0) {
cursor.value = null
return
}
const N = docs.length
const N = documents.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
cursor.value = documents[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p].key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
const key = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
}
}
// @ts-ignore
@@ -199,36 +278,22 @@ const focusBreadcrumb = () => {
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) {
const a = document.querySelector(
`#file-${store.cursor} .name a`
`#file-${cursor.value.key} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
if (!props.documents.length && cursor.value) {
cursor.value = null
focusBreadcrumb()
}
})
let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
})
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Doc, name: string) => {
const mkdir = (doc: Document, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
@@ -245,24 +310,34 @@ const mkdir = (doc: Doc, name: string) => {
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
router.push(`/${doc.loc}/${name}/`)
}
}
})
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
doc.name = name // We should get an update from watch but this is quicker
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
// Column sort
const toggleSort = (name: string) => {
sort.value = sort.value === name ? '' : name
}
const sort = ref<string>('')
const sortCompare = {
name: (a: Document, b: Document) => collator.compare(a.name, b.name),
modified: (a: Document, b: Document) => b.mtime - a.mtime,
size: (a: Document, b: Document) => b.size - a.size
}
const sorted = (documents: Document[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
const sorted = [...documents]
if (cmp) sorted.sort(cmp)
return sorted
}
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) &&
!allSelected.value
)
},
@@ -273,16 +348,16 @@ const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
props.documents.every((doc: Document) => documentStore.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
documentStore.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
documentStore.selected.delete(doc.key)
}
}
}
@@ -290,13 +365,9 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc)
}
</script>
@@ -314,36 +385,29 @@ tbody tr {
position: relative;
z-index: auto;
}
table thead .selection input[type='checkbox'] {
table thead input[type='checkbox'] {
position: inherit;
width: 1rem;
height: 1rem;
padding: 0;
margin: auto;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
}
table tbody .selection input[type='checkbox'] {
table tbody input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 3rem;
width: 2rem;
text-align: center;
text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
}
table .modified {
width: 10rem;
text-overflow: clip;
width: 8em;
}
table .size {
width: 7rem;
text-overflow: clip;
width: 5em;
}
table .menu {
width: 2rem;
width: 1rem;
}
tbody td {
font-size: 1.2rem;
@@ -378,7 +442,7 @@ table td {
}
}
thead tr {
font-size: 0.8rem;
font-size: var(--header-font-size);
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@@ -399,11 +463,9 @@ tbody tr.cursor {
padding-right: 1.5rem;
}
.sortcolumn::after {
font-size: 1rem;
content: '▸';
color: #888;
margin-left: 0.5rem;
margin-top: -.2rem;
margin-left: 0.5em;
position: absolute;
transition: all var(--transition-time) linear;
}
@@ -453,4 +515,3 @@ tbody .selection input {
color: #888;
}
</style>
@/stores/main

View File

@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
import type { Document } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue'
const input = ref<HTMLInputElement | null>(null)
@@ -28,8 +28,8 @@ onMounted(() => {
})
const props = defineProps<{
doc: Doc
rename: (doc: Doc, newName: string) => void
doc: Document
rename: (doc: Document, newName: string) => void
exit: () => void
}>()
@@ -46,8 +46,8 @@ const apply = () => {
<style>
input#FileRenameInput {
color: var(--input-color);
background: var(--input-background);
color: var(--primary-color);
background: var(--primary-background);
border: 0;
border-radius: 0.3rem;
padding: 0.4rem;
@@ -56,10 +56,4 @@ input#FileRenameInput {
outline: none;
font: inherit;
}
.gallery input#FileRenameInput {
padding: .75em;
font-weight: 600;
width: auto;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<object
v-if="props.type === 'pdf'"
:data="dataURL"
type="application/pdf"
width="100%"
height="100%"
></object>
<a-image
v-else-if="props.type === 'image'"
width="50%"
:src="dataURL"
@click="() => setVisible(true)"
:previewMask="false"
:preview="{
visibleImg,
onVisibleChange: setVisible
}"
/>
<!-- Unknown case -->
<h1 v-else>Unsupported file type</h1>
</template>
<script setup lang="ts">
import { watchEffect, ref } from 'vue'
import Router from '@/router/index'
import { url_document_get } from '@/repositories/Document'
const dataURL = ref('')
watchEffect(() => {
dataURL.value = new URL(
url_document_get + Router.currentRoute.value.path,
location.origin
).toString()
})
const emit = defineEmits({
visibleImg(value: boolean) {
return value
}
})
function setVisible(value: boolean) {
emit('visibleImg', value)
}
const props = defineProps<{
type?: string
visibleImg: boolean
}>()
</script>
<style></style>

View File

@@ -0,0 +1,100 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => documentStore.fileExplorer.newFolder()"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
v-model="documentStore.search"
placeholder="Search words"
class="margin-input"
@keyup.escape="closeSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="settingsMenu" />
</div>
</nav>
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const closeSearch = () => {
if (!showSearchInput.value) return // Already closing
showSearchInput.value = false
documentStore.search = ''
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus()
}
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch()
nextTick(() => {
const input = search.value
if (input) input.focus()
})
}
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
} else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
}
ContextMenu.showContextMenu({
// @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
items,
})
}
defineExpose({
toggleSearchInput,
closeSearch,
})
</script>
<style scoped>
.buttons {
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
outline: none;
font-size: 1.5em;
max-width: 30vw;
}
</style>

View File

@@ -1,51 +1,57 @@
<template>
<SvgButton name="download" data-tooltip="Download" @click="download" />
<template v-if="documentStore.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ documentStore.selected.size }} selected </p>
<SvgButton name="download" data-tooltip="Download" @click="download" />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()"></button>
</template>
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
import { reactive } from 'vue';
const store = useMainStore()
const documentStore = useDocumentStore()
const props = defineProps({
path: Array<string>
})
const status_init = {
total: 0,
xfer: 0,
t0: 0,
tlast: 0,
statbytes: 0,
statdur: 0,
files: [] as string[],
filestart: 0,
fileidx: 0,
filecount: 0,
filename: '',
filesize: 0,
filepos: 0,
status: 'idle',
}
store.dprogress = {...status_init}
setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) {
// Reset
store.dprogress.statbytes = 0
store.dprogress.statdur = 1
} else {
// Running average by decay
store.dprogress.statbytes *= .9
store.dprogress.statdur *= .9
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles
const msg = {
op,
sel: sel.keys.map(key => {
const doc = sel.docs[key]
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
const control = connect(controlUrl, {
message(ev: WebSocmetMessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
documentStore.error = res.error.message
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
documentStore.selected.clear()
return
} else console.log('Unknown control response', msg, res)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}, 100)
const statReset = () => {
Object.assign(store.dprogress, status_init)
store.dprogress.t0 = Date.now()
store.dprogress.tlast = store.dprogress.t0 + 1
}
const cancelDownloads = () => {
location.reload() // FIXME
}
const linkdl = (href: string) => {
const a = document.createElement('a')
@@ -58,12 +64,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
let hdir = ''
let h = handle
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
if (doc.dir) continue
store.dprogress.files.push(rel)
++store.dprogress.filecount
store.dprogress.total += doc.size
}
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (hdir && !rel.startsWith(hdir + '/')) {
@@ -72,7 +72,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
if (!dir) continue
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
@@ -96,49 +95,30 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
if (!res.ok)
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
}
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
if (res.body) await res.body.pipeTo(writable)
else {
// Zero-sized files don't have a body, so we need to create an empty file
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
}
await writable.close()
}
await writable.close()
console.log('Saved', hdir + name)
}
statReset()
}
const download = async () => {
const sel = store.selectedFiles
const sel = documentStore.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.error = 'No existing files selected'
store.selected.clear()
documentStore.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.error = "Single file via browser downloads"
documentStore.selected.clear()
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
@@ -149,23 +129,25 @@ const download = async () => {
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle)
store.selected.clear()
filesystemdl(sel, handle).then(() => {
documentStore.selected.clear()
})
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
console.log("Falling back to zip download")
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads"
store.selected.clear()
linkdl(`/zip/${Array.from(sel.keys).join('+')}/download.zip`)
documentStore.selected.clear()
}
</script>
<style scoped>
<style>
.select-text {
color: var(--accent-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ModalDialog name="login" title="Authentication required">
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
@@ -39,10 +39,10 @@
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { useDocumentStore } from '@/stores/documents'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const store = useDocumentStore()
const loginForm = reactive({
username: '',

View File

@@ -1,45 +1,34 @@
<template>
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
<dialog ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button @click=close>OK</button>
<button onclick="dialog.close()">OK</button>
</slot>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { ref, onMounted } from 'vue'
const dialog = ref<HTMLDialogElement | null>(null)
const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
}
const props = defineProps<{
title: string,
name: typeof store.dialog,
}>()
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: ''
}
)
const show = () => {
store.dialog = props.name
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
const input = dialog.value!.querySelector('input')
if (input) input.focus()
})
}, 0)
dialog.value!.showModal()
}
defineExpose({ show, close })
watchEffect(() => {
if (dialog.value) show()
defineExpose({ show })
onMounted(() => {
show()
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<template v-for="upload in documentStore.uploadingDocuments" :key="upload.key">
<span>{{ upload.name }}</span>
<div class="progress-container">
<a-progress :percent="upload.progress" />
<CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" />
</div>
</template>
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
const documentStore = useDocumentStore()
function dismissUpload(key: number) {
documentStore.deleteUploadingDocument(key)
}
</script>
<style scoped>
.progress-container {
display: flex;
align-items: center;
}
.close-button:hover {
color: #b81414;
}
</style>

View File

@@ -6,7 +6,7 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { defineAsyncComponent, defineProps } from 'vue'
const props = defineProps<{
name: string

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { h, ref } from 'vue'
const fileUploadButton = ref()
const folderUploadButton = ref()
const documentStore = useDocumentStore()
const open = (placement: any) => openNotification(placement)
const isNotificationOpen = ref(false)
const openNotification = (placement: any) => {
if (!isNotificationOpen.value) {
/*
api.open({
message: `Uploading documents`,
description: h(NotificationLoading),
placement,
duration: 0,
onClose: () => { isNotificationOpen.value = false }
});*/
isNotificationOpen.value = true
}
}
function uploadFileHandler() {
fileUploadButton.value.click()
}
async function load(file: File, start: number, end: number): Promise<ArrayBuffer> {
const reader = new FileReader()
const load = new Promise<Event>(resolve => (reader.onload = resolve))
reader.readAsArrayBuffer(file.slice(start, end))
const event = await load
if (event.target && event.target instanceof FileReader) {
return event.target.result as ArrayBuffer
} else {
throw new Error('Error loading file')
}
}
async function sendChunk(file: File, start: number, end: number) {
const ws = documentStore.wsUpload
if (ws) {
const chunk = await load(file, start, end)
ws.send(
JSON.stringify({
name: file.name,
size: file.size,
start: start,
end: end
})
)
ws.send(chunk)
}
}
async function uploadFileChangeHandler(event: Event) {
const target = event.target as HTMLInputElement
const chunkSize = 1 << 20
if (target && target.files && target.files.length > 0) {
const file = target.files[0]
const numChunks = Math.ceil(file.size / chunkSize)
const document = documentStore.pushUploadingDocuments(file.name)
open('bottomRight')
for (let i = 0; i < numChunks; i++) {
const start = i * chunkSize
const end = Math.min(file.size, start + chunkSize)
const res = await sendChunk(file, start, end)
console.log('progress: ' + (100 * (i + 1)) / numChunks)
console.log('Num Chunks: ' + numChunks)
documentStore.updateUploadingDocuments(document.key, (100 * (i + 1)) / numChunks)
}
}
}
</script>
<template>
<template>
<input
ref="fileUploadButton"
@change="uploadFileChangeHandler"
class="upload-input"
type="file"
multiple
/>
<input
ref="folderUploadButton"
@change="uploadFileChangeHandler"
class="upload-input"
type="file"
webkitdirectory
/>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileUploadButton.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderUploadButton.click()" />
</template>

View File

@@ -0,0 +1,55 @@
export type FUID = string
export type Document = {
loc: string
name: string
key: FUID
size: number
sizedisp: string
mtime: number
modified: string
haystack: string
dir: boolean
}
export type errorEvent = {
error: {
code: number
message: string
redirect: string
}
}
// Raw types the backend /api/watch sends us
export type FileEntry = {
key: FUID
size: number
mtime: number
}
export type DirEntry = {
key: FUID
size: number
mtime: number
dir: DirList
}
export type DirList = Record<string, FileEntry | DirEntry>
export type UpdateEntry = {
name: string
deleted?: boolean
key?: FUID
size?: number
mtime?: number
dir?: DirList
}
// Helper structure for selections
export interface SelectedItems {
keys: FUID[]
docs: Record<FUID, Document>
recursive: Array<[string, string, Document]>
missing: Set<FUID>
}

View File

@@ -0,0 +1,15 @@
import Client from '@/repositories/Client'
export const url_login = '/login'
export const url_logout = '/logout '
export async function loginUser(username: string, password: string) {
const user = await Client.post(url_login, {
username,
password
})
return user
}
export async function logoutUser() {
const data = await Client.post(url_logout)
return data
}

View File

@@ -1,33 +1,14 @@
import { useMainStore } from "@/stores/main"
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
import { useDocumentStore } from "@/stores/documents"
import type { DirEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch'
let tree = [] as FileEntry[]
let reconnDelay = 500
let tree = null as DirEntry | null
let reconnectDuration = 500
let wsWatch = null as WebSocket | null
export const loadSession = () => {
const s = localStorage['cista-files']
if (!s) return false
const store = useMainStore()
try {
tree = JSON.parse(s)
store.updateRoot(tree)
console.log(`Loaded session with ${tree.length} items cached`)
return true
} catch (error) {
console.log("Loading session failed", error)
return false
}
}
const saveSession = () => {
localStorage["cista-files"] = JSON.stringify(tree)
}
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
@@ -39,7 +20,7 @@ export const watchConnect = () => {
clearTimeout(watchTimeout)
watchTimeout = null
}
const store = useMainStore()
const store = useDocumentStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error)
@@ -53,7 +34,7 @@ export const watchConnect = () => {
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
@@ -61,13 +42,12 @@ export const watchConnect = () => {
}
if ("server" in msg) {
console.log('Connected to backend', msg)
store.server = msg.server
store.connected = true
reconnDelay = 500
reconnectDuration = 500
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.dialog = 'login'
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
}
})
}
@@ -81,21 +61,16 @@ export const watchDisconnect = () => {
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useMainStore()
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
if (watchTimeout !== null) clearTimeout(watchTimeout)
// Don't hammer the server while on login dialog
if (store.dialog === 'login') {
watchTimeout = setTimeout(watchReconnect, 100)
return
}
reconnDelay = Math.min(5000, reconnDelay + 500)
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
watchTimeout = setTimeout(watchConnect, reconnDelay)
if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnectDuration)
}
@@ -118,43 +93,41 @@ const handleWatchMessage = (event: MessageEvent) => {
}
}
function handleRootMessage({ root }: { root: FileEntry[] }) {
const store = useMainStore()
function handleRootMessage({ root }: { root: DirEntry }) {
const store = useDocumentStore()
console.log('Watch root', root)
store.updateRoot(root)
tree = root
saveSession()
}
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useMainStore()
const update = updateData.update
console.log('Watch update', update)
const store = useDocumentStore()
console.log('Watch update', updateData.update)
if (!tree) return console.error('Watch update before root')
let newtree = []
let oidx = 0
for (const [action, arg] of update) {
if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg
let node: DirEntry = tree
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...arg)
else console.log("Unknown update action", action, arg)
if (elem.name) {
// @ts-ignore
console.log(node, elem.name)
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
store.updateRoot(newtree)
tree = newtree
saveSession()
store.updateRoot(tree)
}
function handleError(msg: errorEvent) {
const store = useMainStore()
const store = useDocumentStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false
store.dialog = 'login'
return
}
}

View File

@@ -0,0 +1,183 @@
import type {
Document,
DirEntry,
FileEntry,
FUID,
SelectedItems
} from '@/repositories/Document'
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
import { defineStore } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {
[filename: string]: FileData
}
type User = {
username: string
privileged: boolean
isOpenLoginModal: boolean
isLoggedIn: boolean
}
export const useDocumentStore = defineStore({
id: 'documents',
state: () => ({
document: [] as Document[],
search: "" as string,
selected: new Set<FUID>(),
uploadingDocuments: [],
uploadCount: 0 as number,
fileExplorer: null,
error: '' as string,
connected: false,
user: {
username: '',
privileged: false,
isLoggedIn: false,
isOpenLoginModal: false
} as User
}),
persist: {
storage: sessionStorage,
paths: ['document'],
},
actions: {
updateRoot(root: DirEntry | null = null) {
if (!root) {
this.document = []
return
}
// Transform tree data to flat documents array
let loc = ""
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
...attr,
loc,
name,
sizedisp: formatSize(attr.size),
modified: formatUnixDate(attr.mtime),
haystack: haystackFormat(name),
})
const queue = [...Object.entries(root.dir ?? {}).map(mapper)]
const docs = []
for (let doc; (doc = queue.shift()) !== undefined;) {
docs.push(doc)
if ("dir" in doc) {
// Recurse but replace recursive structure with boolean
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
queue.push(...Object.entries(doc.dir).map(mapper))
// @ts-ignore
doc.dir = true
}
// @ts-ignore
else doc.dir = false
}
// Pre sort directory entries folders first then files, names in natural ordering
docs.sort((a, b) =>
// @ts-ignore
b.dir - a.dir ||
collator.compare(a.name, b.name)
)
this.document = docs as Document[]
},
updateUploadingDocuments(key: number, progress: number) {
for (const d of this.uploadingDocuments) {
if (d.key === key) d.progress = progress
}
},
pushUploadingDocuments(name: string) {
this.uploadCount++
const document = {
key: this.uploadCount,
name: name,
progress: 0
}
this.uploadingDocuments.push(document)
return document
},
deleteUploadingDocument(key: number) {
this.uploadingDocuments = this.uploadingDocuments.filter(e => e.key !== key)
},
updateModified() {
for (const d of this.document) {
if ('mtime' in d) d.modified = formatUnixDate(d.mtime)
}
},
login(username: string, privileged: boolean) {
this.user.username = username
this.user.privileged = privileged
this.user.isLoggedIn = true
this.user.isOpenLoginModal = false
if (!this.connected) watchConnect()
},
loginDialog() {
this.user.isOpenLoginModal = true
},
async logout() {
console.log("Logout")
await logoutUser()
this.$reset()
history.go() // Reload page
}
},
getters: {
isUserLogged(): boolean {
return this.user.isLoggedIn
},
recentDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.mtime - a.mtime)
return ret
},
largeDocuments(): Document[] {
const ret = [...this.document]
ret.sort((a, b) => b.size - a.size)
return ret
},
selectedFiles(): SelectedItems {
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
missing: new Set(),
docs: {},
keys: [],
recursive: [],
}
for (const doc of this.document) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
ret.docs[doc.key] = doc
}
}
// What did we not select?
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Document) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
ret.recursive.push([rel, full, doc])
}
for (const key of ret.keys) {
const base = ret.docs[key]
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret
}
}
})

View File

@@ -86,7 +86,7 @@ export function haystackFormat(str: string) {
// Preformat search string for faster search
export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\s+/)}
return {based, words: based.split(/\W+/)}
}
// Test if haystack includes needle

View File

@@ -0,0 +1,58 @@
<template>
<FileExplorer
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:path="props.path"
:documents="documents"
v-if="props.path"
/>
</template>
<script setup lang="ts">
import { watchEffect, ref, computed } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils';
const documentStore = useDocumentStore()
const fileExplorer = ref()
const props = defineProps({
path: Array<string>
})
const documents = computed(() => {
if (!props.path) return []
const loc = props.path.join('/')
// List the current location
if (!documentStore.search) return documentStore.document.filter(doc => doc.loc === loc)
// Find up to 100 newest documents that match the search
const search = documentStore.search
const needle = needleFormat(search)
let limit = 100
let docs = []
for (const doc of documentStore.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) {
docs.push(doc)
if (--limit === 0) break
}
}
// Organize by folder, by relevance
const locsub = loc + '/'
docs.sort((a, b) => (
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
collator.compare(a.loc, b.loc) ||
// @ts-ignore
(a.type === 'file') - (b.type === 'file') ||
// @ts-ignore
b.name.includes(search) - a.name.includes(search) ||
collator.compare(a.name, b.name)
))
return docs
})
watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value
})
</script>

View File

@@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all'
import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
@@ -20,6 +21,7 @@ const dev_backend = {
export default defineConfig({
plugins: [
vue(),
pluginRewriteAll(),
svgLoader(), // import svg files
Components(), // auto import components
],
@@ -42,9 +44,7 @@ export default defineConfig({
"/files": dev_backend,
"/login": dev_backend,
"/logout": dev_backend,
"/password-change": dev_backend,
"/zip": dev_backend,
"/preview": dev_backend,
}
},
build: {

View File

@@ -1,4 +1,3 @@
import os
import sys
from pathlib import Path
@@ -62,17 +61,13 @@ def _main():
path = None
_confdir(args)
exists = config.conffile.exists()
print(config.conffile, exists)
import_droppy = args["--import-droppy"]
necessary_opts = exists or import_droppy or path
necessary_opts = exists or import_droppy or path and listen
if not necessary_opts:
# Maybe run without arguments
print(doc)
print(
"No config file found! Get started with one of:\n"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n",
)
return 1
settings = {}
@@ -84,15 +79,8 @@ def _main():
settings = droppy.readconf()
if path:
settings["path"] = path
elif not exists:
settings["path"] = Path.home() / "Downloads"
if listen:
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
if not exists and not import_droppy:
# We have no users, so make it public
settings["public"] = True
operation = config.update_config(settings)
print(f"Config {operation}: {config.conffile}")
# Prepare to serve
@@ -117,31 +105,18 @@ def _confdir(args):
if confdir.exists() and not confdir.is_dir():
if confdir.name != config.conffile.name:
raise ValueError("Config path is not a directory")
# Accidentally pointed to the db.toml, use parent
# Accidentally pointed to the cista.toml, use parent
confdir = confdir.parent
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir() # Uses environ if available
config.conffile = config.conffile.with_parent(confdir)
def _user(args):
_confdir(args)
if config.conffile.exists():
config.load_config()
operation = False
else:
# Defaults for new config when user is created
operation = config.update_config(
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"public": False,
}
)
print(f"Config {operation}: {config.conffile}\n")
config.load_config()
name = args["--user"]
if not name or not name.isidentifier():
raise ValueError("Invalid username")
config.load_config()
u = config.config.users.get(name)
info = f"User {name}" if u else f"New user {name}"
changes = {}
@@ -153,17 +128,12 @@ def _user(args):
info += " (admin)" if oldadmin else ""
if args["--password"] or not u:
changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n"
res = config.update_user(name, changes)
info += f"\n Password: {pw}"
res = config.update_user(args["--user"], changes)
print(info)
if res == "read":
print(" No changes")
if operation == "created":
print(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -37,18 +37,10 @@ async def upload(req, ws):
)
req = msgspec.json.decode(text, type=FileRange)
pos = req.start
while True:
data = await ws.recv()
if not isinstance(data, bytes):
break
if len(data) > req.end - pos:
raise ValueError(
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
)
data = None
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
sentsize = await alink(("upload", req.name, pos, data, req.size))
pos += typing.cast(int, sentsize)
if pos >= req.end:
break
if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
@@ -96,7 +88,7 @@ async def watch(req, ws):
msgspec.json.encode(
{
"server": {
"name": config.config.name or config.config.path.name,
"name": "Cista", # Should be configurable
"version": __version__,
"public": config.config.public,
},
@@ -111,24 +103,13 @@ async def watch(req, ws):
)
uuid = token_bytes(16)
try:
q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws
)
await ws.send(space)
await ws.send(root)
with watching.tree_lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
await ws.send(watching.format_du())
await ws.send(watching.format_tree())
# Send updates
while True:
await ws.send(await q.get())
finally:
del watching.pubsub[uuid]
def subscribe(uuid, ws):
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
return (
q,
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)

View File

@@ -1,25 +1,20 @@
import asyncio
import datetime
import mimetypes
import threading
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG
from importlib.resources import files
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip
from sanic.log import logging
from cista import auth, config, preview, session, watching
from cista import auth, config, session, watching
from cista.api import bp
from cista.protocol import DirEntry
from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged
@@ -27,28 +22,19 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
setproctitle("cista-main")
@app.before_server_start
async def main_start(app, loop):
config.load_config()
setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker"
)
await watching.start(app, loop)
app.ctx.threadexec = ThreadPoolExecutor(max_workers=8)
@app.after_server_stop
async def main_stop(app, loop):
quit.set()
await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
@@ -57,8 +43,8 @@ async def main_stop(app, loop):
async def use_session(req):
req.ctx.session = session.get(req)
try:
req.ctx.username = req.ctx.session["username"] # type: ignore
req.ctx.user = config.config.users[req.ctx.username]
req.ctx.username = req.ctx.session["username"]
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
@@ -89,16 +75,22 @@ def http_fileserver(app, _):
www = {}
@app.before_server_start
async def load_wwwroot(*_ignored):
global www
www = await asyncio.get_event_loop().run_in_executor(None, _load_wwwroot, www)
def _load_wwwroot(www):
wwwnew = {}
base = Path(__file__).with_name("wwwroot")
paths = [PurePath()]
base = files("cista") / "wwwroot"
paths = ["."]
while paths:
path = paths.pop(0)
current = base / path
for p in current.iterdir():
if p.is_dir():
paths.append(p.relative_to(base))
paths.append(current / p.parts[-1])
continue
name = p.relative_to(base).as_posix()
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
@@ -129,63 +121,31 @@ def _load_wwwroot(www):
if len(br) >= len(data):
br = False
wwwnew[name] = data, br, headers
if not wwwnew:
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www:
logger.warning(msg)
if not app.debug:
msg = "Web frontend missing. Cista installation is broken.\n"
wwwnew[""] = (
msg.encode(),
False,
{
"etag": "error",
"content-type": "text/plain",
"cache-control": "no-store",
},
)
return wwwnew
@app.before_server_start
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
async def load_wwwroot(app):
global www
www = await asyncio.get_event_loop().run_in_executor(
app.ctx.threadexec, _load_wwwroot, www
)
quit = threading.Event()
@app.add_task
async def refresh_wwwroot():
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
while True:
try:
wwwold = www
await load_wwwroot()
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print("Error loading wwwroot", e)
if not app.debug:
return
await asyncio.sleep(0.5)
@app.route("/<path:path>", methods=["GET", "HEAD"])
@@ -200,78 +160,77 @@ async def wwwroot(req, path=""):
return empty(304, headers=headers)
# Brotli compressed?
if br and "br" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"}
headers = {
**headers,
"content-encoding": "br",
}
data = br
return raw(data, headers=headers)
@app.route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon(req):
# Browsers keep asking for it when viewing files (not HTML with icon link)
return redirect("/assets/logo-97d1d7eb.svg", status=308)
import datetime
from collections import deque
from pathlib import Path
from stat import S_IFREG
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath()
idx = 0
ret = []
level: int | None = None
parent: PurePosixPath | None = None
with watching.state.lock:
root = watching.state.root
while idx < len(root):
f = root[idx]
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if parent is not None and f.level <= level:
level = parent = None
if f.key in wanted:
level, parent = f.level, loc.parent
if parent is not None:
wanted.discard(f.key)
ret.append((loc.relative_to(parent), watching.rootpath / loc))
idx += 1
return ret
from stream_zip import ZIP_AUTO, stream_zip
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
wanted = set(keys.split("+"))
files = get_files(wanted)
with watching.tree_lock:
q = deque([([], None, watching.tree[""].dir)])
files = []
while q:
locpar, relpar, d = q.pop()
for name, attr in d.items():
loc = [*locpar, name]
rel = None
if relpar or attr.key in wanted:
rel = [*relpar, name] if relpar else [name]
wanted.remove(attr.key)
if isinstance(attr, DirEntry):
q.append((loc, rel, attr.dir))
elif rel:
files.append(
(
"/".join(rel),
Path(watching.rootpath.joinpath(*loc)),
attr.mtime,
attr.size,
)
)
if not files:
raise NotFound(
"No files found",
context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "wanted": wanted},
context={"keys": keys, "zipfile": zipfile, "wanted": wanted},
)
if wanted:
raise NotFound("Files not found", context={"missing": wanted})
def local_files(files):
for rel, p in files:
s = p.stat()
size = s.st_size
modified = datetime.datetime.fromtimestamp(s.st_mtime, datetime.UTC)
name = rel.as_posix()
if p.is_dir():
yield f"{name}/", modified, S_IFDIR | 0o755, ZIP_AUTO(size), iter(b"")
else:
yield name, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p, size)
for rel, p, mtime, size in files:
if not p.is_file():
raise NotFound(f"File not found {rel}")
def contents(name, size):
def local_files(files):
for rel, p, mtime, size in files:
modified = datetime.datetime.fromtimestamp(mtime, datetime.UTC)
yield rel, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p)
def contents(name):
with name.open("rb") as f:
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk)
while chunk := f.read(65536):
yield chunk
assert size == 0
def worker():
try:
for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
except Exception:
logger.exception("Error streaming ZIP")
logging.exception("Error streaming ZIP")
raise
finally:
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
@@ -282,10 +241,7 @@ async def zip_download(req, keys, zipfile, ext):
thread = loop.run_in_executor(app.ctx.threadexec, worker)
# Stream the response
res = await req.respond(
content_type="application/zip",
headers={"cache-control": "no-store"},
)
res = await req.respond(content_type="application/zip")
while chunk := await queue.get():
await res.send(chunk)

View File

@@ -68,10 +68,10 @@ def verify(request, *, privileged=False):
if request.ctx.user:
if request.ctx.user.privileged:
return
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
raise Forbidden("Access Forbidden: Only for privileged users")
elif config.config.public or request.ctx.user:
return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
bp = Blueprint("auth")
@@ -159,35 +159,3 @@ async def logout_post(request):
res = json({"message": msg})
session.delete(res)
return res
@bp.post("/password-change")
async def change_password(request):
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
pwchange = request.json["passwordChange"]
password = request.json["password"]
else:
username = request.form["username"][0]
pwchange = request.form["passwordChange"][0]
password = request.form["password"][0]
if not username or not password:
raise KeyError
except KeyError:
raise BadRequest(
"Missing username, passwordChange or password",
) from None
try:
user = login(username, password)
set_password(user, pwchange)
except ValueError as e:
raise Forbidden(str(e), context={"redirect": "/login"}) from e
if "text/html" in request.headers.accept:
res = redirect("/")
session.flash(res, "Password updated")
else:
res = json({"message": "Password updated"})
session.create(res, username)
return res

View File

@@ -1,9 +1,6 @@
from __future__ import annotations
import os
import secrets
import sys
from contextlib import suppress
from functools import wraps
from hashlib import sha256
from pathlib import Path, PurePath
@@ -17,7 +14,6 @@ class Config(msgspec.Struct):
listen: str
secret: str = secrets.token_hex(12)
public: bool = False
name: str = ""
users: dict[str, User] = {}
links: dict[str, Link] = {}
@@ -35,23 +31,7 @@ class Link(msgspec.Struct, omit_defaults=True):
config = None
conffile = None
def init_confdir():
if p := os.environ.get("CISTA_HOME"):
home = Path(p)
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
home = (
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
)
if not home.is_dir():
home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700)
global conffile
conffile = home / "db.toml"
conffile = Path.home() / ".local/share/cista/db.toml"
def derived_secret(*params, len=8) -> bytes:
@@ -79,8 +59,8 @@ def dec_hook(typ, obj):
def config_update(modify):
global config
if conffile is None:
init_confdir()
if not conffile.exists():
conffile.parent.mkdir(parents=True, exist_ok=True)
tmpname = conffile.with_suffix(".tmp")
try:
f = tmpname.open("xb")
@@ -94,6 +74,10 @@ def config_update(modify):
old = conffile.read_bytes()
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError:
# No existing config file, make sure we have a folder...
confdir = conffile.parent
confdir.mkdir(parents=True, exist_ok=True)
confdir.chmod(0o700)
old = b""
c = None
c = modify(c)
@@ -105,10 +89,6 @@ def config_update(modify):
return "read"
f.write(new)
f.close()
if sys.platform == "win32":
# Windows doesn't support atomic replace
with suppress(FileNotFoundError):
conffile.unlink()
tmpname.rename(conffile) # Atomic replace
except:
f.close()
@@ -136,8 +116,6 @@ def modifies_config(modify):
def load_config():
global config
if conffile is None:
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
@@ -156,7 +134,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
# Encode into dict, update values with new, convert to Config
try:
u = conf.users[name].__copy__()
except (KeyError, AttributeError):
except KeyError:
u = User()
if "password" in changes:
from . import auth
@@ -165,7 +143,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook)

View File

@@ -34,11 +34,9 @@ class File:
self.open_rw()
assert self.fd is not None
if file_size is not None:
assert pos + len(buffer) <= file_size
os.ftruncate(self.fd, file_size)
if buffer:
os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer)
os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer)
def __getitem__(self, slice):
if self.fd is None:

View File

@@ -1,117 +0,0 @@
import asyncio
import gc
import io
import mimetypes
import urllib.parse
from pathlib import PurePosixPath
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import av
import av.datasets
import fitz # PyMuPDF
from PIL import Image
from sanic import Blueprint, empty, raw
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import config
from cista.util.filename import sanitize
DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 40))
rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel
stat = path.lstat()
etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex()
savename = PurePosixPath(path.name).with_suffix(".webp")
headers = {
"etag": etag,
"last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable"
+ ("" if config.config.public else ", private"),
"content-type": "image/webp",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
if not path.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
)
return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if mimetypes.guess_type(path.name)[0].startswith("video/"):
return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
def process_image(path, *, maxsize, quality):
img = Image.open(path)
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data
try:
rotate_values = {3: 180, 6: 270, 8: 90}
orientation = img._getexif().get(274)
if orientation in rotate_values:
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
except AttributeError:
...
except Exception as e:
logger.error(f"Error rotating preview image: {e}")
# Save as webp
imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=quality, method=4)
return imgdata.getvalue()
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
pdf = fitz.open(path)
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
return pix.pil_tobytes(format="webp", quality=quality, method=4)
def process_video(path, *, maxsize, quality):
with av.open(str(path)) as container:
stream = container.streams.video[0]
stream.codec_context.skip_frame = "NONKEY"
rot = stream.side_data and stream.side_data.get(DISPLAYMATRIX) or 0
container.seek(container.duration // 8)
img = next(container.decode(stream)).to_image()
del stream
img.thumbnail((maxsize, maxsize))
imgdata = io.BytesIO()
if rot:
img = img.rotate(rot, expand=True)
img.save(imgdata, format="webp", quality=quality, method=4)
del img
ret = imgdata.getvalue()
del imgdata
gc.collect()
return ret

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