84 Commits

Author SHA1 Message Date
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
136 changed files with 2366 additions and 4425 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.*
*.lock
!.gitignore
__pycache__/
*.egg-info/

132
README.md
View File

@@ -1,123 +1,25 @@
# Cista Web Storage
# Web File 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**.
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
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
### Running the Server
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
Create an account: (otherwise the server is public for all)
```fish
uvx cista --user yourname --privileged
Run directly from repository with Hatch (or use pip install as usual):
```sh
hatch run cista -l :3000 /path/to/files
```
Serve your files at http://localhost:8000:
```fish
uvx cista -l :8000 /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
```
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
## Build frontend
```fish
pip install cista --break-system-packages
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
```
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
Most admins 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
}
```
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
## 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 UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
Create `/etc/systemd/system/cista@.service`:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
ExecStart=uvx 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
}
```
## 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.
Make sure you have git, uv and bun (or npm) installed.
Backend (Python) setup and run:
```fish
git clone https://git.zi.fi/Vasanko/cista-storage.git
cd cista-storage
uv sync --dev
uv run cista --dev -l :8000 /path/to/files
```
Frontend (Vue/Vite) run the dev server in another terminal:
```fish
cd frontend
bun install
bun run dev
```
Building the package for release (frontend + Python wheel/sdist):
```fish
uv build
```
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
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,6 +1,6 @@
# Cista Vue Frontend
# cista-front
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.
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
@@ -17,30 +17,24 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
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
## Customize configuration
### Run the backend
See [Vite Configuration Reference](https://vitejs.dev/config/).
```fish
uv sync --dev
uv run cista --dev -l :8000
## Project Setup
```sh
npm install
```
### And the Vite server (in another terminal)
### Compile and Hot-Reload for Development
```fish
cd frontend
bun install
bun run dev
```sh
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 `uv build` during Python packaging:
```fish
bun run build
```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,18 +12,16 @@
"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",
"esbuild": "^0.19.5",
"locale-includes": "^1.0.5",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"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>

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

@@ -0,0 +1,138 @@
<template>
<LoginModal />
<header>
<HeaderMain ref="headerMain">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" />
</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, watchEffect } from 'vue'
import createWebSocket from '@/repositories/WS'
import {
url_document_watch_ws,
url_document_upload_ws,
DocumentHandler,
DocumentUploadHandler
} from '@/repositories/Document'
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
}
})
// Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000)
watchEffect(() => {
const documentHandler = new DocumentHandler()
const documentUploadHandler = new DocumentUploadHandler()
const wsWatch = createWebSocket(
url_document_watch_ws,
documentHandler.handleWebSocketMessage
)
const wsUpload = createWebSocket(
url_document_upload_ws,
documentUploadHandler.handleWebSocketMessage
)
documentStore.wsWatch = wsWatch
documentStore.wsUpload = wsUpload
})
const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
const c = documentStore.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)) {
documentStore.fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
else if (
c &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
) {
documentStore.fileExplorer.toggleSortColumn(+event.key)
}
// Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
documentStore.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)
documentStore.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
documentStore.fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
documentStore.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

@@ -3,83 +3,66 @@
:root {
--primary-color: #000;
--primary-background: #ddd;
--header-background: var(--soft-color);
--header-background: #246;
--header-color: #ccc;
--input-background: #fff;
--input-color: #000;
--primary-color: #000;
--soft-color: #146;
--accent-color: #f80;
--transition-time: 0.2s;
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: 4rem;
--header-height: calc(8 * var(--header-font-size));
}
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #ddd;
--primary-background: var(--soft-color);
--primary-background: #003;
--header-background: #000;
--header-color: #ccc;
--input-background: var(--soft-color);
--input-color: #ddd;
}
}
@media screen and (max-width: 600px) {
@media screen and (orientation: portrait) and (max-width: 600px) {
.size,
.modified,
.summary {
.modified {
display: none;
}
}
@media screen and (min-width: 1000px) {
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
@media screen and (orientation: landscape) and (min-width: 600px) {
/* Breadcrumbs and buttons side by side */
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
.breadcrumb {
font-size: 1.7em;
flex-shrink: 10;
}
}
@media screen and (min-width: 2000px) {
:root {
--root-font-size: 1.5rem;
@media screen and (min-width: 800px) and (--webkit-min-device-pixel-ratio: 2) {
html {
font-size: 1.5rem;
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 1400px) and (--webkit-min-device-pixel-ratio: 3) {
html {
font-size: 2rem;
}
}
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
: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;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
--header-font-size: calc(16 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-font-size: 0.5rem; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header {
display: flex;
justify-content: space-between;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
}
@media print {
@@ -89,10 +72,10 @@
--header-background: none;
--header-color: black;
}
.headermain,
nav,
.menu,
.rename-button {
display: none !important;
display: none;
}
.breadcrumb > a {
color: black !important;
@@ -107,31 +90,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;
@@ -148,13 +116,13 @@
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 */
@@ -171,7 +139,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);
@@ -213,28 +180,23 @@ 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 {
nav {
/* Position so that tooltips can appear on top of other positioned elements */
position: relative;
z-index: 100;
z-index: 10;
}
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 }
[data-tooltip]:hover:after {
z-index: 101;
z-index: 1000;
content: attr(data-tooltip);
position: absolute;
font-size: 1rem;
@@ -242,7 +204,7 @@ header nav.headermain {
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
transform: translate(calc(1rem + -50%), 100%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
@@ -268,9 +230,3 @@ header nav.headermain {
opacity: 0;
}
}
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}

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,15 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
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 +20,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 +27,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 +38,24 @@ onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const props = defineProps<{
path: Array<string>
primary?: boolean
}>()
const longest = ref<Array<string>>([])
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
})
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)
links[index].focus()
router.replace(`/${longest.value.slice(0, index).join('/')}`)
}
const move = (dir: number) => {
@@ -78,30 +63,6 @@ const move = (dir: number) => {
if (index < 0 || index > longest.value.length) return
navigate(index)
}
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()
})
})
</script>
<style>
@@ -115,36 +76,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 +111,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb > a:only-child {
.breadcrumb a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -167,9 +123,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 +145,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

@@ -0,0 +1,476 @@
<template>
<table v-if="props.documents.length || editing" @blur="cursor = null">
<thead>
<tr>
<th class="selection">
<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="menu"></th>
</tr>
</thead>
<tbody>
<tr v-if="editing?.key === 'new'" class="folder">
<td class="selection"></td>
<td class="name">
<FileRenameInput
:doc="editing"
:rename="mkdir"
:exit="
() => {
editing = null
}
"
/>
</td>
<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>
<tr
v-for="doc of sorted(props.documents as FolderDocument[])"
:key="doc.key"
:id="`file-${doc.key}`"
:class="{
file: doc.type === 'file',
folder: doc.type === 'folder',
cursor: cursor === doc
}"
@click="cursor = cursor === doc ? null : doc"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<input
type="checkbox"
tabindex="-1"
:checked="documentStore.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? 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-else>
<a
:href="url_for(doc)"
tabindex="-1"
@contextmenu.stop
@focus.stop="cursor = doc"
>{{ doc.name }}</a
>
<button
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
>
🖊
</button>
</template>
</td>
<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>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document, FolderDocument } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS'
import { formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
path: Array<string>
documents: Document[]
}>(),
{}
)
const documentStore = useDocumentStore()
const router = useRouter()
const linkBasePath = computed(() => props.path.join('/'))
const filesBasePath = computed(() => `/files/${linkBasePath.value}`)
const url_for = (doc: FolderDocument) =>
doc.type === 'folder'
? `#${linkBasePath.value}/${doc.name}/`
: `${filesBasePath.value}/${doc.name}`
const cursor = ref<FolderDocument | null>(null)
// File rename
const editing = ref<FolderDocument | null>(null)
const rename = (doc: FolderDocument, newName: string) => {
const oldName = doc.name
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${decodeURIComponent(linkBasePath.value)}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
defineExpose({
newFolder() {
const now = Date.now() / 1000
editing.value = {
key: 'new',
name: 'New Folder',
type: 'folder',
mtime: now,
size: 0,
sizedisp: formatSize(0),
modified: formatUnixDate(now)
}
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const columns = ['', 'name', 'modified', 'size', '']
toggleSort(columns[column])
},
isCursor() {
return cursor.value !== null && editing.value === null
},
cursorRename() {
editing.value = cursor.value
},
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (documentStore.selected.has(doc.key)) {
documentStore.selected.delete(doc.key)
} else {
documentStore.selected.add(doc.key)
}
this.cursorMove(1)
},
cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation)
const documents = sorted(props.documents as FolderDocument[])
if (documents.length === 0) {
cursor.value = null
return
}
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 =
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
const moveto = increment(index, d)
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 = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
}
})
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (cursor.value) {
const a = document.querySelector(
`#file-${cursor.value.key} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
const mkdir = (doc: FolderDocument, name: string) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(`/${linkBasePath.value}/${name}/`)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${decodeURIComponent(linkBasePath.value)}/${name}`
})
)
}
doc.name = name // We should get an update from watch but this is quicker
}
// Column sort
const toggleSort = (name: string) => {
sort.value = sort.value === name ? '' : name
}
const sort = ref<string>('')
const sortCompare = {
name: (a: Document, b: Document) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
}
const sorted = (documents: FolderDocument[]) => {
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: Document) => documentStore.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
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) {
documentStore.selected.add(doc.key)
} else {
documentStore.selected.delete(doc.key)
}
}
}
})
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
})
const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc)
}
</script>
<style scoped>
table {
width: 100%;
table-layout: fixed;
}
thead tr {
position: sticky;
top: 0;
z-index: 2;
}
tbody tr {
position: relative;
z-index: auto;
}
table thead input[type='checkbox'] {
position: inherit;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
}
table tbody input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 2rem;
text-align: center;
text-overflow: clip;
}
table .modified {
width: 8rem;
}
table .size {
width: 4rem;
}
table .menu {
width: 1rem;
}
tbody td {
font-size: 1.2rem;
}
table th,
table td {
padding: 0 0.5rem;
font-weight: normal;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name {
white-space: nowrap;
position: relative;
}
.name .rename-button {
position: absolute;
right: 0;
animation: appear calc(5 * var(--transition-time)) linear;
}
@keyframes appear {
from {
opacity: 0;
}
80% {
opacity: 0;
}
to {
opacity: 1;
}
}
thead tr {
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
}
tbody tr.cursor {
background: var(--accent-color);
}
.right {
text-align: right;
}
.sortcolumn:hover {
cursor: pointer;
}
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn {
padding-right: 1.5rem;
}
.sortcolumn::after {
content: '▸';
color: #888;
margin-left: 0.5em;
position: absolute;
transition: all var(--transition-time) linear;
}
.sortactive::after {
transform: rotate(90deg);
color: var(--accent-color);
}
.name a {
text-decoration: none;
}
tbody .selection input {
z-index: 1;
position: absolute;
opacity: 0;
left: 0.5rem;
top: 0;
}
.selection {
width: 2em;
height: 2em;
}
.selection input:checked {
opacity: 0.7;
}
.file .selection::before {
content: '📄';
font-size: 1.5rem;
}
.folder .selection::before {
height: 2rem;
content: '📁';
font-size: 1.5rem;
}
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
</style>

View File

@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
import type { FolderDocument } 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: FolderDocument
rename: (doc: FolderDocument, 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,82 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
nextTick(() => {
const input = search.value
if (input) input.focus()
else if (searchButton.value) searchButton.value.blur()
executeSearch()
})
}
const executeSearch = () => {
documentStore.setFilter(search.value?.value ?? '')
}
defineExpose({
toggleSearchInput
})
</script>
<template>
<nav>
<div class="buttons">
<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"
class="margin-input"
@keyup.esc="toggleSearchInput"
@input="executeSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
<SvgButton name="cog" @click="console.log('settings menu')" />
</div>
</nav>
</template>
<style scoped>
.buttons {
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
}
.spacer {
flex-grow: 1;
}
.smallgap {
margin-left: 2em;
}
input[type='search'] {
background: var(--primary-background);
color: var(--primary-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
outline: none;
font-size: 1.5em;
max-width: 30vw;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<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 createWebSocket from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
const documentStore = useDocumentStore()
const props = defineProps({
path: Array<string>
})
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles
const msg = {
op,
sel: sel.ids.filter(id => sel.selected.has(id)).map(id => sel.fullpath[id])
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
const control = createWebSocket('/api/control', ev => {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
documentStore.selected.clear()
return
} else console.log('Unknown control respons', msg, res)
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
a.download = ''
a.click()
}
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
let filelist = []
for (const id of sel.ids) {
filelist.push(sel.relpath[id])
}
console.log('Downloading to filesystem', filelist)
for (const id of sel.ids) {
const rel = sel.relpath[id]
const url = sel.url[id] // Only files, not folders
// Create any missing directories
if (!rel.startsWith(hdir)) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) {
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) {
console.error('Failed to create directory', hdir, error)
return
}
console.log('Created', hdir)
}
if (!url) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', hdir + name, error)
return
}
const writable = await fileHandle.createWritable()
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok)
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
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)
await writable.close()
}
console.log('Saved', hdir + name)
}
}
const download = async () => {
const sel = documentStore.selectedFiles
console.log('Download', sel)
if (sel.selected.size === 0) {
console.warn('Attempted download but no files found. Missing:', sel.missing)
documentStore.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const urls = Object.values(sel.url)
if (urls.length === 1) {
documentStore.selected.clear()
return linkdl(urls[0] as string)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
filesystemdl(sel, handle).then(() => {
documentStore.selected.clear()
})
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
linkdl(`/zip/${Array.from(sel.selected).join('+')}/download.zip`)
documentStore.selected.clear()
}
</script>
<style>
.select-text {
color: var(--accent-color);
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<ModalDialog name="login" title="Authentication required">
<button v-if="store.isUserLogged" @click="logout" class="action-button">
Logout {{ store.user.username }}
</button>
<ModalDialog v-if="store.user.isOpenLoginModal" title="Login">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
@@ -7,8 +10,6 @@
id="username"
name="username"
autocomplete="username"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.username"
/>
@@ -18,31 +19,34 @@
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.password"
/>
</div>
<h3 class="error-text">
{{ loginForm.error || '\u00A0' }}
<h3 v-if="loginForm.error.length > 0" class="error-text">
{{ loginForm.error }}
</h3>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="submit" type="submit" value="Login" class="button-login" />
</div>
<input id="submit" type="submit" class="button-login" />
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import { loginUser, logoutUser } 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 logout = async () => {
try {
await logoutUser()
} finally {
location.reload()
}
}
const loginForm = reactive({
username: '',
@@ -55,10 +59,13 @@ const login = async () => {
loginForm.error = ''
confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password)
store.login(msg.data.username, !!msg.data.privileged)
console.log('Logged in', msg)
store.login(msg.username, !!msg.privileged)
} catch (error) {
const httpError = error as ISimpleError
loginForm.error = httpError.message || '🛑 Unknown error'
if (httpError.name) {
loginForm.error = httpError.message
}
} finally {
confirmLoading.value = false
}
@@ -74,28 +81,15 @@ const login = async () => {
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
background-color: var(--secondary-color);
color: var(--secondary-background);
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
.ant-btn-primary:not(:disabled):hover {
background-color: var(--blue-color);
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<dialog ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button onclick="dialog.close()">OK</button>
</slot>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const dialog = ref<HTMLDialogElement | null>(null)
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: ''
}
)
onMounted(() => {
dialog.value!.showModal()
})
</script>
<style>
/* Style for the background */
body:has(dialog[open])::before {
content: '';
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.2em);
z-index: 1000;
}
/* Hide the dialog by default */
dialog[open] {
display: block;
border: none;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
dialog[open] > h1 {
background: #00f;
color: #fff;
font-size: 1rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
}
dialog[open] > div {
padding: 1em 0;
}
</style>

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

@@ -8,9 +8,6 @@ import router from './router'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
import ContextMenu from '@imengyu/vue3-context-menu'
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
const app = createApp(App)
app.config.errorHandler = err => {
/* handle error */
@@ -20,6 +17,6 @@ app.config.errorHandler = err => {
const pinia = createPinia()
pinia.use(piniaPluginPersistedState)
app.use(pinia)
app.use(router)
app.use(ContextMenu)
app.mount('#app')

View File

@@ -12,7 +12,7 @@ class ClientClass {
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
throw new SimpleError(res.status, `HTTP ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg

View File

@@ -0,0 +1,164 @@
import type { DocumentStore } from '@/stores/documents'
import { useDocumentStore } from '@/stores/documents'
import createWebSocket from './WS'
export type FUID = string
type BaseDocument = {
name: string
key: FUID
}
export type FolderDocument = BaseDocument & {
type: 'folder' | 'file'
size: number
sizedisp: string
mtime: number
modified: string
}
export type Document = FolderDocument
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 {
selected: Set<FUID>
missing: Set<FUID>
rootdir: DirList
entries: Record<FUID, FileEntry | DirEntry>
fullpath: Record<FUID, string>
relpath: Record<FUID, string>
url: Record<FUID, string>
ids: FUID[]
}
export const url_document_watch_ws = '/api/watch'
export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files'
export class DocumentHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
this.store.user.isLoggedIn = false
this.store.user.isOpenLoginModal = true
} else {
this.store.error = msg.error.message
}
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
this.store.wsWatch = createWebSocket(
url_document_watch_ws,
this.handleWebSocketMessage
)
}, 1000)
}
switch (true) {
case !!msg.root:
this.handleRootMessage(msg)
break
case !!msg.update:
this.handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
break
case !!msg.error:
this.handleError(msg)
break
default:
}
}
private handleRootMessage({ root }: { root: DirEntry }) {
console.log('Watch root', root)
if (this.store && this.store.root) {
this.store.user.isLoggedIn = true
this.store.root = root
}
}
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
console.log('Watch update', updateData.update)
let node: DirEntry = this.store.root
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name !== undefined) {
// @ts-ignore
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
}
}
private handleError(msg: errorEvent) {
if (msg.error.code === 401) {
this.store.user.isOpenLoginModal = true
this.store.user.isLoggedIn = false
return
}
}
}
export class DocumentUploadHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
switch (true) {
case !!msg.written:
this.handleWrittenMessage(msg)
break
default:
}
}
private handleWrittenMessage(msg: { written: number }) {
// if (this.store && this.store.root) this.store.root = root;
console.log('Written message', msg.written)
}
}

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

@@ -0,0 +1,8 @@
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
const urlObject = new URL(url, location.origin.replace(/^http/, 'ws'))
const webSocket = new WebSocket(urlObject)
webSocket.onmessage = eventHandler
return webSocket
}
export default createWebSocket

View File

@@ -0,0 +1,231 @@
import type {
Document,
DirEntry,
FileEntry,
FUID,
DirList,
SelectedItems
} from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils'
import { defineStore } from 'pinia'
// @ts-ignore
import { localeIncludes } from 'locale-includes'
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 type DocumentStore = {
root: DirEntry
document: Document[]
selected: Set<FUID>
uploadingDocuments: Array<{ key: number; name: string; progress: number }>
uploadCount: number
wsWatch: WebSocket | undefined
wsUpload: WebSocket | undefined
fileExplorer: any
user: User
error: string
}
export const useDocumentStore = defineStore({
id: 'documents',
state: (): DocumentStore => ({
root: {} as DirEntry,
document: [] as Document[],
selected: new Set<FUID>(),
uploadingDocuments: [],
uploadCount: 0 as number,
wsWatch: undefined,
wsUpload: undefined,
fileExplorer: null,
error: '' as string,
user: {
username: '',
privileged: false,
isLoggedIn: false,
isOpenLoginModal: false
} as User
}),
actions: {
updateTable(matched: DirList) {
// Transform data
const dataMapped = []
for (const [name, attr] of Object.entries(matched)) {
const { key, size, mtime } = attr
const element: Document = {
name,
key,
size,
sizedisp: formatSize(size),
mtime,
modified: formatUnixDate(mtime),
type: 'dir' in attr ? 'folder' : 'file'
}
dataMapped.push(element)
}
// Pre sort directory entries folders first then files, names in natural ordering
dataMapped.sort((a, b) =>
a.type === b.type
? a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: 'base'
})
: a.type === 'folder'
? -1
: 1
)
this.document = dataMapped
},
setFilter(filter: string) {
if (filter === '') return this.updateTable({})
function traverseDir(data: DirEntry | FileEntry, path: string) {
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = `${path}/${name}`
if (
localeIncludes(name, filter, {
usage: 'search',
numeric: true,
sensitivity: 'base'
})
) {
matched[fullname.slice(1)] = attr // No initial slash on name
if (!--count) throw Error('Too many matches')
}
traverseDir(attr, fullname)
}
}
let count = 100
const matched: any = {}
try {
traverseDir(this.root, '')
} catch (error: any) {
if (error.message !== 'Too many matches') throw error
}
this.updateTable(matched)
},
setActualDocument(location: string) {
location = decodeURIComponent(location)
let data: FileEntry | DirEntry = this.root
const actualDirArr = []
try {
// Navigate to target folder
for (const dirname of location.split('/').slice(1)) {
if (!dirname) continue
if (!('dir' in data)) throw Error('Target folder not available')
actualDirArr.push(dirname)
data = data.dir[dirname]
}
} catch (error) {
console.error(
'Cannot show requested folder',
location,
actualDirArr.join('/'),
error
)
}
if (!('dir' in data)) {
// Target folder not available
this.document = []
return
}
this.updateTable(data.dir)
},
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
}
},
getters: {
mainDocument(): Document[] {
return this.document
},
isUserLogged(): boolean {
return this.user.isLoggedIn
},
selectedFiles(): SelectedItems {
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = path ? `${path}/${name}` : name
const key = attr.key
// Is this the file we are looking for? Ignore if nested within another selection.
let r = relpath
if (selected.has(key) && !relpath) {
ret.selected.add(key)
ret.rootdir[name] = attr
r = name
} else if (relpath) {
r = `${relpath}/${name}`
}
if (r) {
ret.entries[key] = attr
ret.fullpath[key] = fullname
ret.relpath[key] = r
ret.ids.push(key)
if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
}
traverseDir(attr, fullname, r)
}
}
const selected = this.selected
const ret: SelectedItems = {
selected: new Set<FUID>(),
missing: new Set<FUID>(),
rootdir: {} as DirList,
entries: {} as Record<FUID, FileEntry | DirEntry>,
fullpath: {} as Record<FUID, string>,
relpath: {} as Record<FUID, string>,
url: {} as Record<FUID, string>,
ids: [] as FUID[]
}
traverseDir(this.root, '', '')
// What did we not select?
for (const id of selected) {
if (!ret.selected.has(id)) ret.missing.add(id)
}
// Sorted array of FUIDs for easy traversal
ret.ids.sort((a, b) =>
ret.relpath[a].localeCompare(ret.relpath[b], undefined, {
numeric: true,
sensitivity: 'base'
})
)
return ret
}
}
})

View File

@@ -50,47 +50,51 @@ export function formatUnixDate(t: number) {
}
export function getFileExtension(filename: string) {
const dotIndex = filename.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === filename.length - 1) {
return '' // No extension
const parts = filename.split('.')
if (parts.length > 1) {
return parts[parts.length - 1]
} else {
return '' // No hay extensión
}
return filename.slice(dotIndex + 1)
}
interface FileTypes {
[key: string]: string[]
export function getFileType(extension: string): string {
const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif']
const pdfExtensions = ['pdf']
if (videoExtensions.includes(extension)) {
return 'video'
} else if (imageExtensions.includes(extension)) {
return 'image'
} else if (pdfExtensions.includes(extension)) {
return 'pdf'
} else {
return 'unknown'
}
}
const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'],
}
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
export function getFileType(name: string): string {
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
}
// Prebuilt for fast & consistent sorting
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
// Preformat document names for faster search
export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return '^' + based + '$'
}
// 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+/)}
}
// Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
const {based, words} = filter
export function localeIncludes(haystack: string, based: string, words: string[]) {
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
}
export function buildCorpus(data: any[]) {
return data.map(item => [haystackFormat(item.name), item])
}
export function search(corpus: [string, any][], search: string) {
const based = search.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
const words = based.split(/\W+/)
const ret = []
for (const [haystack, item] of corpus) {
if (localeIncludes(haystack, based, words))
ret.push(item)
}
return ret
}

View File

@@ -0,0 +1,28 @@
<template>
<FileExplorer
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:path="props.path"
:documents="documentStore.mainDocument"
/>
</template>
<script setup lang="ts">
import { watchEffect, ref } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index'
const documentStore = useDocumentStore()
const fileExplorer = ref()
const props = defineProps({
path: Array<string>
})
watchEffect(() => {
documentStore.fileExplorer = fileExplorer.value
})
watchEffect(async () => {
const path = new String(Router.currentRoute.value.path) as string
documentStore.setActualDocument(path.toString())
})
</script>

View File

@@ -4,11 +4,12 @@ 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'
// Development mode:
// bun run dev # Run frontend that proxies to dev_backend
// npm run dev # Run frontend that proxies to dev_backend
// cista -l :8000 --dev # Run backend
const dev_backend = {
target: "http://localhost:8000",
@@ -20,6 +21,7 @@ const dev_backend = {
export default defineConfig({
plugins: [
vue(),
pluginRewriteAll(),
svgLoader(), // import svg files
Components(), // auto import components
],
@@ -42,9 +44,6 @@ 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")
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

@@ -1,11 +1,10 @@
import asyncio
import typing
from secrets import token_bytes
import msgspec
from sanic import Blueprint
from cista import __version__, config, watching
from cista import watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@@ -37,18 +36,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}")
@@ -92,43 +83,14 @@ async def control(req, ws):
@bp.websocket("watch")
@websocket_wrapper
async def watch(req, ws):
await ws.send(
msgspec.json.encode(
{
"server": {
"name": config.config.name or config.config.path.name,
"version": __version__,
"public": config.config.public,
},
"user": {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
}
).decode()
)
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[ws] = 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),
)
del watching.pubsub[ws]

View File

@@ -1,24 +1,16 @@
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 cista import auth, config, preview, session, watching
from cista import auth, config, session, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -27,40 +19,27 @@ 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.after_server_stop
async def main_stop(app, loop):
quit.set()
await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
@app.on_request
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.user = config.config.users[req.ctx.session["username"]] # type: ignore
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
# CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket":
@@ -89,16 +68,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,47 +114,15 @@ 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():
while True:
try:
wwwold = www
await load_wwwroot(app)
await load_wwwroot()
changes = ""
for name in sorted(www):
attr = www[name]
@@ -182,10 +135,10 @@ async def refresh_wwwroot():
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print(f"Error loading wwwroot: {e!r}")
print("Error loading wwwroot", e)
if not app.debug:
return
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@app.route("/<path:path>", methods=["GET", "HEAD"])
@@ -200,93 +153,9 @@ 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)
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
@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)
if not files:
raise NotFound(
"No files found",
context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "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)
def contents(name, size):
with name.open("rb") as f:
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk)
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()
except Exception:
logger.exception("Error streaming ZIP")
raise
finally:
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread
queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop()
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"},
)
while chunk := await queue.get():
await res.send(chunk)
await thread # If it raises, the response will fail download

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,9 +34,7 @@ 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)

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