Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
953ec628a0 | ||
|
|
e678c8c267 | ||
|
|
69d58f99e3 | ||
|
|
36764885ed | ||
|
|
5a82560cf2 | ||
|
|
7b1c6f6772 | ||
|
|
c025e7af95 | ||
|
|
3bad311e35 | ||
|
|
fdc4fe0a3e | ||
|
|
5df2308bdb | ||
|
|
f4c44ce1aa | ||
|
|
49232f11cc | ||
|
|
1258eff42d | ||
|
|
718d46e3f9 | ||
|
|
92d9c40a28 | ||
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc | ||
|
|
bf8a049b92 | ||
|
|
6d7f44bd88 | ||
|
|
e2097a1563 | ||
|
|
b864936eaa | ||
|
|
72b3c0d8ce | ||
|
|
07daf372e8 | ||
|
|
e979d679b2 | ||
|
|
eea66c0013 | ||
|
|
9220c457c0 | ||
|
|
d5b77932ea | ||
|
|
9b9d3e1cc1 | ||
|
|
536efc4ce1 | ||
|
|
1b2267587f | ||
|
|
2864e9f041 | ||
|
|
b5a94b5eee | ||
|
|
d4be755d46 | ||
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 | ||
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 | ||
|
|
af804e2c9f | ||
|
|
1dc0c4441a | ||
|
|
c7ba0d5a04 | ||
|
|
0071058b29 | ||
|
|
338c74de69 | ||
|
|
b13f08eab2 | ||
|
|
5d566d6deb | ||
|
|
5c4965a86b | ||
|
|
bd7291e9ef | ||
|
|
2fa52229cc | ||
|
|
2dea459d8f | ||
|
|
922069c603 | ||
|
|
5c7c7343ad | ||
|
|
593d16d8c5 | ||
|
|
20d8d317fa | ||
|
|
8d89c397a4 | ||
|
|
1bec73f4cd | ||
|
|
4dd1d4c7e6 | ||
|
|
84ef91a360 | ||
|
|
421d90e9c5 | ||
|
|
8b4e622aef | ||
|
|
13f32c57ab | ||
|
|
25a2a5f20c | ||
|
|
041090cce9 | ||
|
|
ec6db7b53f | ||
|
|
575df1214b | ||
|
|
1927c24053 | ||
|
|
6242c76be8 | ||
|
|
87a92838c2 | ||
|
|
8c93a4f2b5 | ||
|
|
eb5ff82de6 | ||
|
|
942b54d795 | ||
|
|
18ee0f3f56 | ||
|
|
f8b2c9494a | ||
|
|
9ac571463a | ||
|
|
d52b24372a | ||
|
|
742b3006c9 | ||
|
|
01a979c00a | ||
|
|
f1e16b7abe | ||
|
|
0ebff0ec17 | ||
|
|
c8ab06d864 | ||
|
|
d5ff7757c8 | ||
|
|
cd604eb10a | ||
|
|
abcf5d9940 | ||
|
|
0190bda853 | ||
|
|
fc48500412 | ||
|
|
d31ad0b525 | ||
|
|
da4bba95be | ||
|
|
e07ab220cb | ||
|
|
3da2f6e6c3 | ||
|
|
ded7ce65bc | ||
|
|
17550be698 | ||
|
|
0b269aef7f | ||
|
|
497de296f2 | ||
|
|
134b216f4c | ||
|
|
06759b3c12 | ||
|
|
c51552ea29 | ||
|
|
00645fc8ff | ||
|
|
760f7bc35d | ||
|
|
8480a73839 | ||
|
|
302ed684e7 | ||
|
|
af35e0480a | ||
|
|
5717486197 | ||
|
|
0061fc54ae | ||
|
|
4eefe83072 | ||
|
|
f578a50007 | ||
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 | ||
|
|
41686d1dd1 | ||
|
|
cc351bb992 | ||
|
|
c3abbe0a3b | ||
|
|
127caeedea | ||
|
|
113bc56351 | ||
|
|
c7727c72d9 | ||
|
|
85b3aa6b81 | ||
|
|
62b44ddb43 | ||
|
|
60a53ef3d3 | ||
|
|
acd38c2235 | ||
|
|
2f38f15afa | ||
|
|
0fc28e56bb | ||
|
|
22b0e503e0 | ||
|
|
e3a4ecdcc2 | ||
|
|
9f363e3f66 | ||
|
|
8270dd0cc2 | ||
|
|
1af6cd82fe | ||
|
|
0bc2a12cfa | ||
|
|
9e07a3f40d | ||
|
|
2867b1075d | ||
|
|
4fc8e29cc1 | ||
|
|
55f9117b3e | ||
|
|
2b6746c55e | ||
|
|
1ed3779933 | ||
|
|
a95cf7be94 | ||
|
|
d18501f633 | ||
|
|
ed697db871 | ||
|
|
856e8c4cc8 | ||
|
|
f8a6eacb06 | ||
|
|
e9a82e84ad | ||
|
|
ccd05b53f4 | ||
|
|
032ad13b97 | ||
|
|
4eae75c84b | ||
|
|
252b31a293 | ||
|
|
feff202161 | ||
|
|
d15bfc86c4 | ||
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a | ||
|
|
98949e6b30 | ||
|
|
8f76d770ee | ||
|
|
232fd92b22 | ||
|
|
be69164c8f | ||
|
|
4f39875786 | ||
|
|
21250a1a2d | ||
|
|
849b1a6868 | ||
|
|
7be02e951d | ||
|
|
bb38328c24 | ||
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf | ||
|
|
1cb512e65d | ||
|
|
972aaee9fe | ||
|
|
055eaa8a21 | ||
|
|
05fb81c36d | ||
|
|
6639174e8f | ||
|
|
5e2e71eafb | ||
|
|
7e4c5bc911 | ||
|
|
bfcce1b80e | ||
|
|
2bd8d4a323 | ||
|
|
1d97d48fed | ||
|
|
f627890e55 | ||
|
|
5a5b47346f | ||
|
|
64de17a74b | ||
|
|
55133e82f3 |
@@ -1,7 +1,9 @@
|
|||||||
.*
|
.*
|
||||||
|
*.lock
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.pre-commit-config.yaml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/cista/_version.py
|
/cista/_version.py
|
||||||
/cista/wwwroot/*
|
/cista/frontend-build/
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
name: ruff check
|
||||||
|
entry: uv run ruff check .
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: ruff-format-check
|
||||||
|
name: ruff format check
|
||||||
|
entry: uv run ruff format --check .
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: pytest
|
||||||
|
name: pytest
|
||||||
|
entry: uv run pytest
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: frontend-type-check
|
||||||
|
name: frontend type-check
|
||||||
|
entry: npm --prefix frontend run type-check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: frontend-biome-check
|
||||||
|
name: frontend biome check
|
||||||
|
entry: npm --prefix frontend run check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
@@ -6,7 +6,9 @@ Cista takes its name from the ancient *cistae*, metal containers used by Greeks
|
|||||||
|
|
||||||
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**.
|
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||||
|
|
||||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
**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 was not being developed at the time. Now they have picked up pace too, feel free to try both and compare.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -14,39 +16,100 @@ Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and
|
|||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
### Installation
|
|
||||||
|
|
||||||
To install the cista application, use:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
pip install cista
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
|
|
||||||
|
|
||||||
### Running the Server
|
### Running the Server
|
||||||
|
|
||||||
Create an account: (or run a public server without authentication)
|
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||||
|
|
||||||
|
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||||
```fish
|
```fish
|
||||||
cista --user yourname --privileged
|
uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an account: (otherwise the server is public for all)
|
||||||
|
```fish
|
||||||
|
uvx cista --user yourname --privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
Serve your files at http://localhost:8000:
|
Serve your files at http://localhost:8000:
|
||||||
```fish
|
```fish
|
||||||
cista -l :8000 /path/to/files
|
uvx cista -l :8000 /path/to/files
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
|
||||||
|
|
||||||
|
```fish
|
||||||
|
pip install cista --break-system-packages
|
||||||
```
|
```
|
||||||
|
|
||||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
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
|
## Authentication
|
||||||
|
|
||||||
To use your own TLS certificates, place them in the config folder and run:
|
Cista supports two authentication modes, each supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
|
|
||||||
|
### Public Mode
|
||||||
|
|
||||||
|
In public mode, anyone can read, send and even delete files without without logging in. Users entering the service won't be asked to authenticate. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||||
|
|
||||||
|
### Built-in Password Authentication (default)
|
||||||
|
|
||||||
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
cista -l cista.example.com
|
uvx cista --user admin --privileged # Create admin user
|
||||||
|
uvx cista --user guest # Create regular user
|
||||||
```
|
```
|
||||||
|
|
||||||
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
|
|
||||||
|
### Passkey Authentication and SSO
|
||||||
|
|
||||||
|
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. This allows user account and permission management at the corporate level, without bothering Cista with it.
|
||||||
|
|
||||||
|
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the Paskia backend on the same machine (to use that default URL):
|
||||||
|
```fish
|
||||||
|
uvx paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
In Paskia mode:
|
||||||
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
|
- Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
|
||||||
|
- Users with `cista:login` permission can access files
|
||||||
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
|
|
||||||
|
## WebDAV Access
|
||||||
|
|
||||||
|
Cista supports WebDAV, so you can mount it as a network drive or browse it directly from your operating system's file manager.
|
||||||
|
|
||||||
|
Connect to `https://cista.example.com/files/`.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **Standard users:** Use your username and password with Basic auth.
|
||||||
|
- **API tokens:** For scripts, backup tools, or when your client requires NTLM (e.g. Windows File Explorer), create a token in the web interface via **🔑 API Tokens**. Authenticate with username `token` and the token secret as the password.
|
||||||
|
|
||||||
|
### Supported clients
|
||||||
|
|
||||||
|
| Client | Setup |
|
||||||
|
|--------|-------|
|
||||||
|
| **Windows File Explorer** | Map Network Drive → `https://cista.example.com/files/` (or Add a network location). Windows may try NTLM first; API tokens are recommended. |
|
||||||
|
| **macOS Finder** | Go → Connect to Server (⌘K) → `https://cista.example.com/files/` |
|
||||||
|
| **Linux (GNOME/KDE)** | Enter `davs://cista.example.com/files/` or `webdavs://cista.example.com/files/` in the location bar |
|
||||||
|
| **Android — Solid Explorer** | Tap **+** → New Cloud Connection → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
|
||||||
|
| **Android — CX File Explorer** | Open the **Network** tab → **New location** → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. |
|
||||||
|
| **Cyberduck, WinSCP, rclone** | Standard WebDAV profile with Basic auth |
|
||||||
|
|
||||||
|
**Note on Windows NTLM:** Windows WebDAV clients often require NTLM authentication, which is incompatible with Cista's Argon2 password hashes. API tokens solve this — Cista uses the token secret as the NTLM password.
|
||||||
|
|
||||||
|
### 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`:
|
`/etc/caddy/Caddyfile`:
|
||||||
|
|
||||||
@@ -56,35 +119,21 @@ cista.example.com {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development setup
|
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`
|
||||||
|
|
||||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
|
||||||
|
|
||||||
```fish
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Concurrently, start the backend on another terminal:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
hatch shell
|
|
||||||
pip install -e '.[dev]'
|
|
||||||
cista --dev -l :8000 /path/to/files
|
|
||||||
```
|
|
||||||
|
|
||||||
We use `hatch shell` for installing on a virtual environment, to avoid disturbing the rest of the system with our hacking.
|
|
||||||
|
|
||||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
|
|
||||||
|
|
||||||
## System Deployment
|
## System Deployment
|
||||||
|
|
||||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||||
|
|
||||||
Assuming a restricted user account `storage` for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
|
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 javascript runtimes.
|
||||||
|
|
||||||
Create `/etc/systemd/system/cista@.service`:
|
Create (edit) a systemd unit:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
sudo systemctl edit --force --full cista@.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -92,8 +141,9 @@ Description=Cista storage %i
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=storage
|
User=storage
|
||||||
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||||
Restart=always
|
Restart=always
|
||||||
|
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -116,3 +166,34 @@ foo.example.com, bar.example.com {
|
|||||||
reverse_proxy unix//srv/cista/{host}/socket
|
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/frontend-build`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from cista._version import __version__
|
from cista._version import __version__ as __version__
|
||||||
|
|
||||||
__version__ # Public API
|
|
||||||
|
|||||||
@@ -5,34 +5,90 @@ from pathlib import Path
|
|||||||
from docopt import docopt
|
from docopt import docopt
|
||||||
|
|
||||||
import cista
|
import cista
|
||||||
from cista import app, config, droppy, serve, server80
|
from cista import app, config, droppy, onlyoffice, serve, server80
|
||||||
|
from cista.sso import PASKIA_BACKEND_URL
|
||||||
from cista.util import pwgen
|
from cista.util import pwgen
|
||||||
|
|
||||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||||
|
|
||||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
|
||||||
|
|
||||||
|
def create_banner():
|
||||||
|
"""Create a framed banner with the Cista version."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
subtitle = "A file storage for the web"
|
||||||
|
width = max(len(title), len(subtitle)) + 4
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
╭{"─" * width}╮
|
||||||
|
│{title:^{width}}│
|
||||||
|
│{subtitle:^{width}}│
|
||||||
|
╰{"─" * width}╯
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_startup_box(
|
||||||
|
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||||
|
):
|
||||||
|
"""Create a framed startup box with server information."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
listen = unix or url
|
||||||
|
location = f"{folder} @ {listen}"
|
||||||
|
lines = [title, location]
|
||||||
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
|
auth_line = f"Auth: Paskia {paskia_url}" if paskia_url else "Auth: Password"
|
||||||
|
if public:
|
||||||
|
auth_line += ", Public"
|
||||||
|
lines.append(auth_line)
|
||||||
|
if dev:
|
||||||
|
lines.append("dev mode")
|
||||||
|
|
||||||
|
# Calculate width based on content
|
||||||
|
inner_width = max(len(line) for line in lines) + 2
|
||||||
|
|
||||||
|
# Build the box
|
||||||
|
box = [f"╭{'─' * inner_width}╮"]
|
||||||
|
box.extend(f"│ {line:<{inner_width - 1}}│" for line in lines)
|
||||||
|
box.append(f"╰{'─' * inner_width}╯")
|
||||||
|
return "\n".join(box) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
banner = create_banner()
|
||||||
|
|
||||||
|
_default_confdir = (
|
||||||
|
(Path(os.environ["XDG_CONFIG_HOME"]) / "cista").as_posix()
|
||||||
|
if os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
else (Path.home() / ".config/cista").as_posix()
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = f"""\
|
||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
|
cista [-c <confdir>] --oosetup
|
||||||
|
cista --version
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c CONFDIR Custom config directory
|
-c CONFDIR Config directory [{_default_confdir}]
|
||||||
-l LISTEN-ADDR Listen on
|
-l, --listen ADDR Listen on address (port, :port, /socket or domain for https)
|
||||||
:8000 (localhost port, plain http)
|
--import-droppy Import Droppy config from ~/.droppy/config
|
||||||
<addr>:3000 (bind another address, port)
|
--dev Developer mode (reloads, friendlier crashes, more logs)
|
||||||
/path/to/unix.sock (unix socket)
|
--user NAME Create or modify a user account (when server is not running)
|
||||||
example.com (run on 80 and 443 with LetsEncrypt)
|
--privileged Grant admin rights
|
||||||
--import-droppy Import Droppy config from ~/.droppy/config
|
--password Reset password
|
||||||
--dev Developer mode (reloads, friendlier crashes, more logs)
|
--oosetup Build and run OnlyOffice in Docker for document previews
|
||||||
|
|
||||||
Listen address, path and imported options are preserved in config, and only
|
Environment:
|
||||||
custom config dir and dev mode need to be specified on subsequent runs.
|
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
||||||
|
https://git.zi.fi/leovasanko/paskia
|
||||||
|
ONLYOFFICE_CISTA_URL, ONLYOFFICE_JWT_SECRET, ONLYOFFICE_CALLBACK_HOST (if needed)
|
||||||
|
"""
|
||||||
|
|
||||||
User management:
|
first_time_help = """\
|
||||||
--user NAME Create or modify user
|
No config file found! Get started with:
|
||||||
--privileged Give the user full admin rights
|
cista --user yourname --privileged # If you want user accounts
|
||||||
--password Reset password
|
cista -l :8989 /path/to/files # Run the server on localhost:8989
|
||||||
|
|
||||||
|
See cista --help for other options!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -44,15 +100,24 @@ def main():
|
|||||||
try:
|
try:
|
||||||
return _main()
|
return _main()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error:", e)
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _main():
|
def _main():
|
||||||
|
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
|
||||||
|
if any(arg in sys.argv for arg in ("--help", "-h")):
|
||||||
|
sys.stdout.write(banner)
|
||||||
|
elif "--version" in sys.argv:
|
||||||
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
|
return 0
|
||||||
|
# Don't print banner yet for normal startup - we'll print the startup box later
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
listen = args["-l"]
|
if args["--oosetup"]:
|
||||||
|
return onlyoffice.setup_docker(_resolve_confdir(args))
|
||||||
|
listen = args["--listen"]
|
||||||
# Validate arguments first
|
# Validate arguments first
|
||||||
if args["<path>"]:
|
if args["<path>"]:
|
||||||
path = Path(args["<path>"]).resolve()
|
path = Path(args["<path>"]).resolve()
|
||||||
@@ -62,18 +127,11 @@ def _main():
|
|||||||
path = None
|
path = None
|
||||||
_confdir(args)
|
_confdir(args)
|
||||||
exists = config.conffile.exists()
|
exists = config.conffile.exists()
|
||||||
print(config.conffile, exists)
|
|
||||||
import_droppy = args["--import-droppy"]
|
import_droppy = args["--import-droppy"]
|
||||||
necessary_opts = exists or import_droppy or path
|
necessary_opts = exists or import_droppy or path
|
||||||
if not necessary_opts:
|
if not necessary_opts:
|
||||||
# Maybe run without arguments
|
# Maybe run without arguments
|
||||||
print(doc)
|
sys.stderr.write(first_time_help)
|
||||||
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"
|
|
||||||
)
|
|
||||||
return 1
|
return 1
|
||||||
settings = {}
|
settings = {}
|
||||||
if import_droppy:
|
if import_droppy:
|
||||||
@@ -82,6 +140,7 @@ def _main():
|
|||||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
||||||
)
|
)
|
||||||
settings = droppy.readconf()
|
settings = droppy.readconf()
|
||||||
|
# Droppy's public flag is kept as-is (same name in our config)
|
||||||
if path:
|
if path:
|
||||||
settings["path"] = path
|
settings["path"] = path
|
||||||
elif not exists:
|
elif not exists:
|
||||||
@@ -89,38 +148,46 @@ def _main():
|
|||||||
if listen:
|
if listen:
|
||||||
settings["listen"] = listen
|
settings["listen"] = listen
|
||||||
elif not exists:
|
elif not exists:
|
||||||
settings["listen"] = ":8000"
|
settings["listen"] = ":8989"
|
||||||
if not exists and not import_droppy:
|
config.update_config(settings)
|
||||||
# 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
|
# Prepare to serve
|
||||||
unix = None
|
url, opts = serve.parse_listen(config.config.listen)
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
|
||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
extra = f" ({unix})" if unix else ""
|
|
||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
# Print startup box
|
||||||
extra += " (dev mode)"
|
startup_box = create_startup_box(
|
||||||
print(f"Serving {config.config.path} at {url}{extra}")
|
folder=config.config.path,
|
||||||
|
url=url,
|
||||||
|
unix=opts.get("unix"),
|
||||||
|
dev=dev,
|
||||||
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
public=config.config.public,
|
||||||
|
)
|
||||||
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _confdir(args):
|
def _resolve_confdir(args):
|
||||||
|
confdir = None
|
||||||
if args["-c"]:
|
if args["-c"]:
|
||||||
# Custom config directory
|
# Custom config directory
|
||||||
confdir = Path(args["-c"]).resolve()
|
confdir = Path(args["-c"]).resolve()
|
||||||
if confdir.exists() and not confdir.is_dir():
|
if confdir.exists() and not confdir.is_dir():
|
||||||
if confdir.name != config.conffile.name:
|
if confdir.name != "db.toml":
|
||||||
raise ValueError("Config path is not a directory")
|
raise ValueError("Config path is not a directory")
|
||||||
# Accidentally pointed to the db.toml, use parent
|
# Accidentally pointed to the db.toml, use parent
|
||||||
confdir = confdir.parent
|
confdir = confdir.parent
|
||||||
|
return confdir
|
||||||
|
|
||||||
|
|
||||||
|
def _confdir(args):
|
||||||
|
confdir = _resolve_confdir(args)
|
||||||
|
if confdir is not None:
|
||||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||||
config.init_confdir() # Uses environ if available
|
config.init_confdir()
|
||||||
|
|
||||||
|
|
||||||
def _user(args):
|
def _user(args):
|
||||||
@@ -132,12 +199,12 @@ def _user(args):
|
|||||||
# Defaults for new config when user is created
|
# Defaults for new config when user is created
|
||||||
operation = config.update_config(
|
operation = config.update_config(
|
||||||
{
|
{
|
||||||
"listen": ":8000",
|
"listen": ":8989",
|
||||||
"path": Path.home() / "Downloads",
|
"path": Path.home() / "Downloads",
|
||||||
"public": False,
|
"public": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
print(f"Config {operation}: {config.conffile}\n")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
|
||||||
|
|
||||||
name = args["--user"]
|
name = args["--user"]
|
||||||
if not name or not name.isidentifier():
|
if not name or not name.isidentifier():
|
||||||
@@ -155,13 +222,13 @@ def _user(args):
|
|||||||
changes["password"] = pw = pwgen.generate()
|
changes["password"] = pw = pwgen.generate()
|
||||||
info += f"\n Password: {pw}\n"
|
info += f"\n Password: {pw}\n"
|
||||||
res = config.update_user(name, changes)
|
res = config.update_user(name, changes)
|
||||||
print(info)
|
sys.stderr.write(f"{info}\n")
|
||||||
if res == "read":
|
if res == "read":
|
||||||
print(" No changes")
|
sys.stderr.write(" No changes\n")
|
||||||
|
|
||||||
if operation == "created":
|
if operation == "created":
|
||||||
print(
|
sys.stderr.write(
|
||||||
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
"Now you can run the server:\n cista # defaults set: -l :8989 ~/Downloads\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,64 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
|
||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint, json
|
||||||
|
from sanic.exceptions import BadRequest
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import __version__, config, watching
|
from cista import __version__, auth, config, onlyoffice, sharefs, sso, watching
|
||||||
|
from cista.auth import (
|
||||||
|
create_share_token_handler,
|
||||||
|
create_token_handler,
|
||||||
|
delete_token_handler,
|
||||||
|
list_tokens_handler,
|
||||||
|
)
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.util.apphelpers import websocket_wrapper
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
|
||||||
|
|
||||||
bp = Blueprint("api", url_prefix="/api")
|
bp = Blueprint("api", url_prefix="/api")
|
||||||
fileserver = FileServer()
|
fileserver = FileServer()
|
||||||
|
|
||||||
|
|
||||||
@bp.before_server_start
|
@bp.before_server_start
|
||||||
async def start_fileserver(app, _):
|
async def start_fileserver(app):
|
||||||
|
_ = app
|
||||||
await fileserver.start()
|
await fileserver.start()
|
||||||
|
|
||||||
|
|
||||||
@bp.after_server_stop
|
@bp.after_server_stop
|
||||||
async def stop_fileserver(app, _):
|
async def stop_fileserver(app):
|
||||||
|
_ = app
|
||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("upload")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def upload(req, ws):
|
|
||||||
alink = fileserver.alink
|
|
||||||
while True:
|
|
||||||
req = None
|
|
||||||
text = await ws.recv()
|
|
||||||
if not isinstance(text, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
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}")
|
|
||||||
# Report success
|
|
||||||
res = StatusMsg(status="ack", req=req)
|
|
||||||
await asend(ws, res)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("download")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def download(req, ws):
|
|
||||||
alink = fileserver.alink
|
|
||||||
while True:
|
|
||||||
req = None
|
|
||||||
text = await ws.recv()
|
|
||||||
if not isinstance(text, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
|
||||||
)
|
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
|
||||||
pos = req.start
|
|
||||||
while pos < req.end:
|
|
||||||
end = min(req.end, pos + (1 << 20))
|
|
||||||
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
|
|
||||||
await asend(ws, data)
|
|
||||||
pos += len(data)
|
|
||||||
# Report success
|
|
||||||
res = StatusMsg(status="ack", req=req)
|
|
||||||
await asend(ws, res)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("control")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def control(req, ws):
|
|
||||||
while True:
|
|
||||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
|
||||||
await asyncio.to_thread(cmd)
|
|
||||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("watch")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
|
# Build user info from either built-in auth or SSO
|
||||||
|
user_info = None
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
|
try:
|
||||||
|
# WebSocket cannot forward Set-Cookie, so ask the auth backend not to
|
||||||
|
# renew the session here; renewal happens on the HTTP side instead.
|
||||||
|
await sso.validate_sso_request(req, renew=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("watch SSO validation failed: %s", e)
|
||||||
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
|
ctx = sso_user.get("ctx", {})
|
||||||
|
perms = ctx.get("permissions", [])
|
||||||
|
user_info = {
|
||||||
|
"username": ctx.get("user", {}).get("display_name", ""),
|
||||||
|
"privileged": "cista:admin" in perms,
|
||||||
|
}
|
||||||
|
elif req.ctx.user:
|
||||||
|
# Built-in auth: use local user database
|
||||||
|
user_info = {
|
||||||
|
"username": req.ctx.username,
|
||||||
|
"privileged": req.ctx.user.privileged,
|
||||||
|
}
|
||||||
|
|
||||||
await ws.send(
|
await ws.send(
|
||||||
msgspec.json.encode(
|
msgspec.json.encode(
|
||||||
{
|
{
|
||||||
@@ -99,31 +66,45 @@ async def watch(req, ws):
|
|||||||
"name": config.config.name or config.config.path.name,
|
"name": config.config.name or config.config.path.name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"public": config.config.public,
|
"public": config.config.public,
|
||||||
|
"paskia": sso.paskia_enabled(),
|
||||||
|
"office_previews": await onlyoffice.is_available_cached(),
|
||||||
},
|
},
|
||||||
"user": {
|
"user": user_info,
|
||||||
"username": req.ctx.username,
|
|
||||||
"privileged": req.ctx.user.privileged,
|
|
||||||
}
|
|
||||||
if req.ctx.user
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
).decode()
|
).decode()
|
||||||
)
|
)
|
||||||
uuid = token_bytes(16)
|
uuid = token_bytes(16)
|
||||||
|
share_token = auth.request_share_token(req)
|
||||||
try:
|
try:
|
||||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||||
)
|
)
|
||||||
await ws.send(space)
|
await ws.send(space)
|
||||||
await ws.send(root)
|
if share_token is None:
|
||||||
|
await ws.send(root)
|
||||||
|
else:
|
||||||
|
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
|
||||||
# Send updates
|
# Send updates
|
||||||
while True:
|
while True:
|
||||||
await ws.send(await q.get())
|
msg = await q.get()
|
||||||
|
if share_token is None or (
|
||||||
|
isinstance(msg, str) and msg.startswith('{"space"')
|
||||||
|
):
|
||||||
|
await ws.send(msg)
|
||||||
|
else:
|
||||||
|
await ws.send(
|
||||||
|
watching.format_root(sharefs.build_virtual_root(share_token))
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if str(e) == "cannot schedule new futures after shutdown":
|
||||||
|
return # Server shutting down, drop the WebSocket
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
del watching.pubsub[uuid]
|
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
|
||||||
|
|
||||||
|
|
||||||
def subscribe(uuid, ws):
|
def subscribe(uuid, ws):
|
||||||
|
_ = ws
|
||||||
with watching.state.lock:
|
with watching.state.lock:
|
||||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||||
# Init with disk usage and full tree
|
# Init with disk usage and full tree
|
||||||
@@ -132,3 +113,63 @@ def subscribe(uuid, ws):
|
|||||||
watching.format_space(watching.state.space),
|
watching.format_space(watching.state.space),
|
||||||
watching.format_root(watching.state.root),
|
watching.format_root(watching.state.root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("config")
|
||||||
|
async def get_config(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"name": config.config.name,
|
||||||
|
"public": config.config.public,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/public")
|
||||||
|
async def update_public(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
public = request.json["public"]
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing public field") from None
|
||||||
|
if not isinstance(public, bool):
|
||||||
|
raise BadRequest("public must be a boolean")
|
||||||
|
config.update_config({"public": public})
|
||||||
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/name")
|
||||||
|
async def update_name(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
name = request.json["name"]
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing name field") from None
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise BadRequest("name must be a string")
|
||||||
|
config.update_config({"name": name})
|
||||||
|
# Return the effective name (fallback to path.name if empty)
|
||||||
|
effective_name = name or config.config.path.name
|
||||||
|
return json({"message": "Server name updated", "name": effective_name})
|
||||||
|
|
||||||
|
|
||||||
|
# Token management endpoints (available in all modes; primary path in SSO mode)
|
||||||
|
@bp.get("tokens")
|
||||||
|
async def list_api_tokens(request):
|
||||||
|
return await list_tokens_handler(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("tokens")
|
||||||
|
async def create_api_token(request):
|
||||||
|
return await create_token_handler(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("tokens/<token_id>")
|
||||||
|
async def delete_api_token(request, token_id):
|
||||||
|
return await delete_token_handler(request, token_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("share-tokens")
|
||||||
|
async def create_share_token(request):
|
||||||
|
return await create_share_token_handler(request)
|
||||||
|
|||||||
@@ -1,67 +1,64 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import threading
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from multiprocessing import cpu_count
|
|
||||||
from pathlib import Path, PurePath, PurePosixPath
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
from stat import S_IFDIR, S_IFREG
|
from stat import S_IFDIR, S_IFREG
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import brotli
|
import tracerite
|
||||||
import sanic.helpers
|
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
from sanic import Sanic, empty, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import (
|
||||||
|
auth,
|
||||||
|
config,
|
||||||
|
fileserver,
|
||||||
|
onlyoffice,
|
||||||
|
preview,
|
||||||
|
session,
|
||||||
|
sharefs,
|
||||||
|
sso,
|
||||||
|
watching,
|
||||||
|
)
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
|
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||||
|
from cista.sanic_logging import (
|
||||||
|
configure_access_logging,
|
||||||
|
configure_main_logging,
|
||||||
|
format_access_log,
|
||||||
|
)
|
||||||
|
from cista.sanic_logging import logger as access_logger
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
# Workaround until Sanic PR #2824 is merged
|
tracerite.load()
|
||||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
configure_access_logging()
|
||||||
|
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.blueprint(auth.bp)
|
app.router.ALLOWED_METHODS = (
|
||||||
app.blueprint(preview.bp)
|
*app.router.ALLOWED_METHODS,
|
||||||
app.blueprint(bp)
|
"MKCOL",
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
"MOVE",
|
||||||
|
"COPY",
|
||||||
|
"PROPFIND",
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_main_logging()
|
||||||
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
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
req.ctx.session = session.get(req)
|
req.ctx.log_start = time.perf_counter()
|
||||||
try:
|
req.ctx.auth_flow = ["session: start"]
|
||||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||||
req.ctx.user = config.config.users[req.ctx.username]
|
|
||||||
except (AttributeError, KeyError, TypeError):
|
|
||||||
req.ctx.username = None
|
|
||||||
req.ctx.user = None
|
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
return # Ordinary GET requests are fine
|
return # Ordinary GET requests are fine
|
||||||
@@ -72,18 +69,96 @@ async def use_session(req):
|
|||||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.on_response
|
||||||
def http_fileserver(app, _):
|
async def log_access(req, res):
|
||||||
bp = Blueprint("fileserver")
|
"""Log HTTP access in a clean single-line format."""
|
||||||
bp.on_request(auth.verify)
|
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||||
bp.static(
|
return res
|
||||||
"/files/",
|
start = getattr(req.ctx, "log_start", None)
|
||||||
config.config.path,
|
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||||
use_content_range=True,
|
client = req.client_ip or "-"
|
||||||
stream_large_files=True,
|
host = req.host or "-"
|
||||||
directory_view=True,
|
path = req.path
|
||||||
|
if req.query_string:
|
||||||
|
qs = req.query_string
|
||||||
|
if isinstance(qs, bytes):
|
||||||
|
qs = qs.decode(errors="replace")
|
||||||
|
path = f"{path}?{qs}"
|
||||||
|
extra = getattr(req.ctx, "log_extra", None)
|
||||||
|
line = format_access_log(
|
||||||
|
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
|
||||||
)
|
)
|
||||||
app.blueprint(bp)
|
access_logger.info(line)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_response
|
||||||
|
async def forward_sso_cookies(req, res):
|
||||||
|
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||||
|
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||||
|
for cookie in cookies:
|
||||||
|
res.headers.add("set-cookie", cookie)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_response
|
||||||
|
async def persist_auth_session(req, res):
|
||||||
|
"""Persist a session cookie after successful Authorization-based auth."""
|
||||||
|
username = getattr(req.ctx, "create_session_username", None)
|
||||||
|
if not username or res.status >= 400:
|
||||||
|
return
|
||||||
|
existing = getattr(req.ctx, "session", None)
|
||||||
|
if isinstance(existing, dict) and existing.get("username") == username:
|
||||||
|
return
|
||||||
|
session.create(req, res, username)
|
||||||
|
|
||||||
|
|
||||||
|
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||||
|
else:
|
||||||
|
app.blueprint(auth.bp) # Built-in auth routes
|
||||||
|
app.blueprint(preview.bp)
|
||||||
|
app.blueprint(bp)
|
||||||
|
app.blueprint(fileserver.bp)
|
||||||
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
|
|
||||||
|
|
||||||
|
setproctitle("cista-main")
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_server_start
|
||||||
|
async def main_start(app):
|
||||||
|
config.load_config()
|
||||||
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
|
max_workers=4, thread_name_prefix="cista-worker"
|
||||||
|
)
|
||||||
|
# Larger pool for long-running but low-memory zip operations
|
||||||
|
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||||
|
await start_preview_workers()
|
||||||
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_server_start
|
||||||
|
async def main_after_start(app):
|
||||||
|
_ = app
|
||||||
|
onlyoffice.log_reachable_info()
|
||||||
|
|
||||||
|
|
||||||
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
|
@app.before_server_stop
|
||||||
|
async def main_stop(app):
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
tg.create_task(asyncio.to_thread(watching.stop, app))
|
||||||
|
tg.create_task(onlyoffice.close_oo_client())
|
||||||
|
tg.create_task(shutdown_preview_workers())
|
||||||
|
tg.create_task(sso.close_client())
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
|
||||||
|
|
||||||
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
www = {}
|
www = {}
|
||||||
@@ -91,8 +166,9 @@ www = {}
|
|||||||
|
|
||||||
def _load_wwwroot(www):
|
def _load_wwwroot(www):
|
||||||
wwwnew = {}
|
wwwnew = {}
|
||||||
base = Path(__file__).with_name("wwwroot")
|
base = Path(__file__).with_name("frontend-build")
|
||||||
paths = [PurePath()]
|
paths = [PurePath()]
|
||||||
|
zstd = ZstdCompressor(level=18)
|
||||||
while paths:
|
while paths:
|
||||||
path = paths.pop(0)
|
path = paths.pop(0)
|
||||||
current = base / path
|
current = base / path
|
||||||
@@ -124,11 +200,11 @@ def _load_wwwroot(www):
|
|||||||
else "no-cache",
|
else "no-cache",
|
||||||
"content-type": mime,
|
"content-type": mime,
|
||||||
}
|
}
|
||||||
# Precompress with Brotli
|
# Precompress with ZSTD
|
||||||
br = brotli.compress(data)
|
zs = zstd.compress(data)
|
||||||
if len(br) >= len(data):
|
if len(zs) >= len(data):
|
||||||
br = False
|
zs = False
|
||||||
wwwnew[name] = data, br, headers
|
wwwnew[name] = data, zs, headers
|
||||||
if not wwwnew:
|
if not wwwnew:
|
||||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||||
if not www:
|
if not www:
|
||||||
@@ -149,9 +225,8 @@ def _load_wwwroot(www):
|
|||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def start(app):
|
async def start(app):
|
||||||
await load_wwwroot(app)
|
if not app.debug:
|
||||||
if app.debug:
|
await load_wwwroot(app)
|
||||||
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
|
|
||||||
|
|
||||||
|
|
||||||
async def load_wwwroot(app):
|
async def load_wwwroot(app):
|
||||||
@@ -161,84 +236,82 @@ async def load_wwwroot(app):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
quit = threading.Event()
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_wwwroot():
|
|
||||||
try:
|
|
||||||
while not quit.is_set():
|
|
||||||
try:
|
|
||||||
wwwold = www
|
|
||||||
await load_wwwroot(app)
|
|
||||||
changes = ""
|
|
||||||
for name in sorted(www):
|
|
||||||
attr = www[name]
|
|
||||||
if wwwold.get(name) == attr:
|
|
||||||
continue
|
|
||||||
headers = attr[2]
|
|
||||||
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
|
|
||||||
for name in sorted(set(wwwold) - set(www)):
|
|
||||||
changes += f"Deleted /{name}\n"
|
|
||||||
if changes:
|
|
||||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading wwwroot: {e!r}")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||||
async def wwwroot(req, path=""):
|
async def wwwroot(req, path=""):
|
||||||
"""Frontend files only"""
|
"""Frontend files only"""
|
||||||
|
if app.debug:
|
||||||
|
raise NotFound(
|
||||||
|
"Dev mode: frontend-build is not served on backend (you should connect vite)",
|
||||||
|
extra={"name": path},
|
||||||
|
)
|
||||||
name = unquote(path)
|
name = unquote(path)
|
||||||
if name not in www:
|
if name not in www:
|
||||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||||
data, br, headers = www[name]
|
data, zs, headers = www[name]
|
||||||
if req.headers.if_none_match == headers["etag"]:
|
if req.headers.if_none_match == headers["etag"]:
|
||||||
# The client has it cached, respond 304 Not Modified
|
# The client has it cached, respond 304 Not Modified
|
||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
# Brotli compressed?
|
# Zstandard compressed?
|
||||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
if zs and "zstd" in req.headers.accept_encoding.split(", "):
|
||||||
headers = {**headers, "content-encoding": "br"}
|
headers = {**headers, "content-encoding": "zstd"}
|
||||||
data = br
|
data = zs
|
||||||
return raw(data, headers=headers)
|
return raw(data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||||
async def favicon(req):
|
async def favicon(req):
|
||||||
|
_ = req
|
||||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||||
return redirect("/assets/logo-97d1d7eb.svg", status=308)
|
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
||||||
|
|
||||||
|
|
||||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
def get_files(req, wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||||
loc = PurePosixPath()
|
loc = PurePosixPath()
|
||||||
idx = 0
|
idx = 0
|
||||||
ret = []
|
ret = []
|
||||||
level: int | None = None
|
level: int | None = None
|
||||||
parent: PurePosixPath | None = None
|
parent: PurePosixPath | None = None
|
||||||
with watching.state.lock:
|
token = auth.request_share_token(req)
|
||||||
root = watching.state.root
|
|
||||||
while idx < len(root):
|
if token is None:
|
||||||
f = root[idx]
|
with watching.state.lock:
|
||||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
root = watching.state.root
|
||||||
if parent is not None and f.level <= level:
|
while idx < len(root):
|
||||||
level = parent = None
|
f = root[idx]
|
||||||
if f.key in wanted:
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
level, parent = f.level, loc.parent
|
if parent is not None and f.level <= level:
|
||||||
if parent is not None:
|
level = parent = None
|
||||||
wanted.discard(f.key)
|
if f.key in wanted:
|
||||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
level, parent = f.level, loc.parent
|
||||||
idx += 1
|
if parent is not None:
|
||||||
|
wanted.discard(f.key)
|
||||||
|
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||||
|
idx += 1
|
||||||
|
return ret
|
||||||
|
|
||||||
|
root = sharefs.build_virtual_root(token)
|
||||||
|
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)
|
||||||
|
real_path = sharefs.resolve_virtual_rel_to_real(token, loc)
|
||||||
|
ret.append((loc.relative_to(parent), real_path))
|
||||||
|
idx += 1
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
async def zip_download(req, keys, zipfile, ext):
|
||||||
"""Download a zip archive of the given keys"""
|
"""Download a zip archive of the given keys"""
|
||||||
|
await auth.verify(req)
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(req, wanted)
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
raise NotFound(
|
raise NotFound(
|
||||||
@@ -264,29 +337,43 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
||||||
size -= len(chunk)
|
size -= len(chunk)
|
||||||
yield chunk
|
yield chunk
|
||||||
assert size == 0
|
if size != 0:
|
||||||
|
raise OSError(f"stream ended early while zipping {name}")
|
||||||
|
|
||||||
|
pending_put = None # Current queue.put future, can be cancelled
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
|
nonlocal pending_put
|
||||||
try:
|
try:
|
||||||
for chunk in stream_zip(local_files(files)):
|
for chunk in stream_zip(local_files(files)):
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
|
||||||
|
pending_put = future
|
||||||
|
future.result() # Blocks until queue has space
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("ZIP download cancelled by client disconnect")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error streaming ZIP")
|
logger.exception("Error streaming ZIP")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
pending_put = None
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||||
|
|
||||||
# Don't block the event loop: run in a thread
|
# Don't block the event loop: run in a thread (use larger zip pool)
|
||||||
queue = asyncio.Queue(maxsize=1)
|
queue = asyncio.Queue(maxsize=1)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
thread = loop.run_in_executor(app.ctx.zipexec, worker)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
res = await req.respond(
|
res = await req.respond(
|
||||||
content_type="application/zip",
|
content_type="application/zip",
|
||||||
headers={"cache-control": "no-store"},
|
headers={"cache-control": "no-store"},
|
||||||
)
|
)
|
||||||
while chunk := await queue.get():
|
try:
|
||||||
await res.send(chunk)
|
while chunk := await queue.get():
|
||||||
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
# Cancel any pending put to unblock and stop the worker
|
||||||
|
if pending_put:
|
||||||
|
pending_put.cancel()
|
||||||
|
|
||||||
await thread # If it raises, the response will fail download
|
await thread # If it raises, the response will fail download
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from time import time
|
from time import sleep, time
|
||||||
|
from typing import Concatenate, Literal, ParamSpec
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.toml
|
||||||
|
|
||||||
|
from .util import pwhash
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct):
|
||||||
@@ -20,6 +25,14 @@ class Config(msgspec.Struct):
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
users: dict[str, User] = {}
|
users: dict[str, User] = {}
|
||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
tokens: dict[str, Token] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Typing: arguments for config-modifying functions
|
||||||
|
P = ParamSpec("P")
|
||||||
|
ResultStr = Literal["modified", "created", "read"]
|
||||||
|
RawModifyFunc = Callable[Concatenate[Config, P], Config]
|
||||||
|
ModifyPublic = Callable[P, ResultStr]
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, omit_defaults=True):
|
class User(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -34,11 +47,24 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
expires: int = 0
|
expires: int = 0
|
||||||
|
|
||||||
|
|
||||||
config = None
|
class Token(msgspec.Struct, omit_defaults=True):
|
||||||
conffile = None
|
key: str = "" # plain text secret (shown once on creation)
|
||||||
|
username: str = "" # set in built-in mode
|
||||||
|
sso_user_id: str = "" # set in SSO mode
|
||||||
|
name: str = ""
|
||||||
|
created: int = 0
|
||||||
|
kind: str = "api" # api | share
|
||||||
|
mode: str = "rw" # ro | rw
|
||||||
|
share_paths: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
def init_confdir():
|
# Global variables - initialized during application startup
|
||||||
|
config: Config
|
||||||
|
conffile: Path
|
||||||
|
|
||||||
|
|
||||||
|
def init_confdir() -> None:
|
||||||
|
global conffile
|
||||||
if p := os.environ.get("CISTA_HOME"):
|
if p := os.environ.get("CISTA_HOME"):
|
||||||
home = Path(p)
|
home = Path(p)
|
||||||
else:
|
else:
|
||||||
@@ -49,12 +75,10 @@ def init_confdir():
|
|||||||
if not home.is_dir():
|
if not home.is_dir():
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
home.chmod(0o700)
|
home.chmod(0o700)
|
||||||
|
|
||||||
global conffile
|
|
||||||
conffile = home / "db.toml"
|
conffile = home / "db.toml"
|
||||||
|
|
||||||
|
|
||||||
def derived_secret(*params, len=8) -> bytes:
|
def derived_secret(*params, size=8) -> bytes:
|
||||||
"""Used to derive secret keys from the main secret"""
|
"""Used to derive secret keys from the main secret"""
|
||||||
# Each part is made the same length by hashing first
|
# Each part is made the same length by hashing first
|
||||||
combined = b"".join(
|
combined = b"".join(
|
||||||
@@ -62,7 +86,7 @@ def derived_secret(*params, len=8) -> bytes:
|
|||||||
for p in [config.secret, *params]
|
for p in [config.secret, *params]
|
||||||
)
|
)
|
||||||
# Output a bytes of the desired length
|
# Output a bytes of the desired length
|
||||||
return sha256(combined).digest()[:len]
|
return sha256(combined).digest()[:size]
|
||||||
|
|
||||||
|
|
||||||
def enc_hook(obj):
|
def enc_hook(obj):
|
||||||
@@ -77,10 +101,10 @@ def dec_hook(typ, obj):
|
|||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
|
||||||
def config_update(modify):
|
def config_update(
|
||||||
|
modify: RawModifyFunc,
|
||||||
|
) -> ResultStr | Literal["collision"]:
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
|
||||||
init_confdir()
|
|
||||||
tmpname = conffile.with_suffix(".tmp")
|
tmpname = conffile.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
f = tmpname.open("xb")
|
f = tmpname.open("xb")
|
||||||
@@ -95,7 +119,7 @@ def config_update(modify):
|
|||||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
old = b""
|
old = b""
|
||||||
c = None
|
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
|
||||||
c = modify(c)
|
c = modify(c)
|
||||||
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
||||||
if old == new:
|
if old == new:
|
||||||
@@ -118,17 +142,23 @@ def config_update(modify):
|
|||||||
return "modified" if old else "created"
|
return "modified" if old else "created"
|
||||||
|
|
||||||
|
|
||||||
def modifies_config(modify):
|
def modifies_config(
|
||||||
"""Decorator for functions that modify the config file"""
|
modify: Callable[Concatenate[Config, P], Config],
|
||||||
|
) -> Callable[P, ResultStr]:
|
||||||
|
"""Decorator for functions that modify the config file
|
||||||
|
|
||||||
|
The decorated function takes as first arg Config and returns it modified.
|
||||||
|
The wrapper handles atomic modification and returns a string indicating the result.
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(modify)
|
@wraps(modify)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
|
||||||
def m(c):
|
def m(c: Config) -> Config:
|
||||||
return modify(c, *args, **kwargs)
|
return modify(c, *args, **kwargs)
|
||||||
|
|
||||||
# Retry modification in case of write collision
|
# Retry modification in case of write collision
|
||||||
while (c := config_update(m)) == "collision":
|
while (c := config_update(m)) == "collision":
|
||||||
time.sleep(0.01)
|
sleep(0.01)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -136,16 +166,23 @@ def modifies_config(modify):
|
|||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
init_confdir()
|
||||||
init_confdir()
|
raw = conffile.read_bytes()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
|
||||||
|
# Migrate from old authentication field if present
|
||||||
|
raw_dict = msgspec.toml.decode(raw)
|
||||||
|
if "authentication" in raw_dict and "public" not in raw_dict:
|
||||||
|
# Old config with authentication mode: migrate to public bool
|
||||||
|
new_public = raw_dict["authentication"] == "none"
|
||||||
|
config = msgspec.structs.replace(config, public=new_public)
|
||||||
|
update_config({}) # Save the migrated config
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
def update_config(conf: Config, changes: dict) -> Config:
|
def update_config(conf: Config, changes: dict) -> Config:
|
||||||
"""Create/update the config with new values, respecting changes done by others."""
|
"""Create/update the config with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings.update(changes)
|
settings.update(changes)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@@ -155,17 +192,20 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
"""Create/update a user with new values, respecting changes done by others."""
|
"""Create/update a user with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
try:
|
try:
|
||||||
u = conf.users[name].__copy__()
|
# Copy user by converting to dict and back
|
||||||
except (KeyError, AttributeError):
|
u = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
|
||||||
|
User,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
u = User()
|
u = User()
|
||||||
if "password" in changes:
|
if "password" in changes:
|
||||||
from . import auth
|
pwhash.set_password(u, changes["password"])
|
||||||
|
|
||||||
auth.set_password(u, changes["password"])
|
|
||||||
del changes["password"]
|
del changes["password"]
|
||||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||||
udict.update(changes)
|
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)
|
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@@ -173,6 +213,33 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
@modifies_config
|
@modifies_config
|
||||||
def del_user(conf: Config, name: str) -> Config:
|
def del_user(conf: Config, name: str) -> Config:
|
||||||
"""Delete named user account."""
|
"""Delete named user account."""
|
||||||
ret = conf.__copy__()
|
# Create a copy by converting to dict and back
|
||||||
ret.users.pop(name)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
return ret
|
settings["users"].pop(name)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
|
@modifies_config
|
||||||
|
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
|
||||||
|
"""Create or update a token."""
|
||||||
|
try:
|
||||||
|
t = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
|
||||||
|
Token,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
t = Token()
|
||||||
|
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
|
||||||
|
tdict.update(changes)
|
||||||
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
|
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
|
@modifies_config
|
||||||
|
def del_token(conf: Config, token_id: str) -> Config:
|
||||||
|
"""Delete a token by its stable id."""
|
||||||
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
|
settings["tokens"].pop(token_id, None)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Patched OnlyOffice Document Server with configurable converter worker count.
|
||||||
|
#
|
||||||
|
# The Community Edition hardcodes the document converter to 1 worker,
|
||||||
|
# which creates a severe bottleneck under concurrent load.
|
||||||
|
# This image patches the open-source license.js to spawn a configurable
|
||||||
|
# number of converter workers (default 8).
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# docker run -d -p 8988:80 \
|
||||||
|
# -e WORKERS=16 \
|
||||||
|
# -e JWT_SECRET=your-strong-secret \
|
||||||
|
# --name onlyoffice onlyoffice-cista
|
||||||
|
#
|
||||||
|
# JWT:
|
||||||
|
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# OnlyOffice will enable token validation automatically.
|
||||||
|
#
|
||||||
|
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
|
||||||
|
|
||||||
|
ARG ONLYOFFICE_VERSION=9.3.1
|
||||||
|
|
||||||
|
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
|
||||||
|
|
||||||
|
# Prevent interactive apt prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install Node.js, npm, and git so we can run the FileConverter from source.
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Clone the open-source server components (shallow, ~15 MB).
|
||||||
|
# The master branch is used because the Linux/web tags are not published
|
||||||
|
# in the server repo; the license.js file has been stable for years.
|
||||||
|
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
||||||
|
|
||||||
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
|
# instead of being hardcoded to 1.
|
||||||
|
RUN sed -i \
|
||||||
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Back up the compiled pkg binary and replace it with our wrapper.
|
||||||
|
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
|
||||||
|
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
|
||||||
|
|
||||||
|
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
|
||||||
|
# Default worker count (override at runtime with -e WORKERS=16).
|
||||||
|
ENV WORKERS=8
|
||||||
|
|
||||||
|
# Use our custom entrypoint to persist the env var to a file that the
|
||||||
|
# non-root converter process (user=ds) can read.
|
||||||
|
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
|
||||||
|
RUN chmod +x /app/ds/run-document-server-patched.sh
|
||||||
|
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
|
||||||
|
# Replaces the compiled pkg binary shipped with the Community Edition.
|
||||||
|
|
||||||
|
# The env var is not passed through supervisor to the 'ds' user, so we read
|
||||||
|
# it from a file written by the custom entrypoint.
|
||||||
|
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
|
||||||
|
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /opt/oo-server/FileConverter || exit 1
|
||||||
|
|
||||||
|
export NODE_ENV=production-linux
|
||||||
|
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
|
||||||
|
export NODE_DISABLE_COLORS=1
|
||||||
|
export APPLICATION_NAME=onlyoffice
|
||||||
|
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
|
||||||
|
|
||||||
|
exec node sources/convertermaster.js "$@"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Custom entrypoint that persists WORKERS to a file readable by
|
||||||
|
# the non-root user that supervisor uses to run the converter.
|
||||||
|
|
||||||
|
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
|
||||||
|
chmod 644 /tmp/oo-converter-workers.txt
|
||||||
|
|
||||||
|
exec /app/ds/run-document-server.sh "$@"
|
||||||
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
|
|||||||
for listener in cf["listeners"]:
|
for listener in cf["listeners"]:
|
||||||
try:
|
try:
|
||||||
if listener["protocol"] == "https":
|
if listener["protocol"] == "https":
|
||||||
# TODO: Add support for TLS
|
# TLS listeners are currently ignored here.
|
||||||
continue
|
continue
|
||||||
socket = listener.get("socket")
|
socket = listener.get("socket")
|
||||||
if socket:
|
if socket:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import asyncio
|
import errno
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
from cista.util.asynclink import AsyncLink
|
from cista.util.diskspace import InsufficientStorageError, check_free_space
|
||||||
from cista.util.lrucache import LRUCache
|
from cista.util.lrucache import LRUCache
|
||||||
|
|
||||||
|
|
||||||
@@ -32,20 +34,34 @@ class File:
|
|||||||
if not self.writable:
|
if not self.writable:
|
||||||
# Create/open file
|
# Create/open file
|
||||||
self.open_rw()
|
self.open_rw()
|
||||||
assert self.fd is not None
|
if self.fd is None:
|
||||||
|
raise RuntimeError("file descriptor is not available for write")
|
||||||
|
check_free_space(self.path)
|
||||||
if file_size is not None:
|
if file_size is not None:
|
||||||
assert pos + len(buffer) <= file_size
|
if pos + len(buffer) > file_size:
|
||||||
os.ftruncate(self.fd, file_size)
|
raise ValueError("write exceeds declared file size")
|
||||||
|
try:
|
||||||
|
os.ftruncate(self.fd, file_size)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
if buffer:
|
if buffer:
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
os.write(self.fd, buffer)
|
try:
|
||||||
|
os.write(self.fd, buffer)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
|
|
||||||
def __getitem__(self, slice):
|
def __getitem__(self, slc):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
self.open_ro()
|
self.open_ro()
|
||||||
assert self.fd is not None
|
if self.fd is None:
|
||||||
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
raise RuntimeError("file descriptor is not available for read")
|
||||||
size = slice.stop - slice.start
|
os.lseek(self.fd, slc.start, os.SEEK_SET)
|
||||||
|
size = slc.stop - slc.start
|
||||||
data = os.read(self.fd, size)
|
data = os.read(self.fd, size)
|
||||||
if len(data) < size:
|
if len(data) < size:
|
||||||
raise EOFError("Error reading requested range")
|
raise EOFError("Error reading requested range")
|
||||||
@@ -62,38 +78,32 @@ class File:
|
|||||||
|
|
||||||
class FileServer:
|
class FileServer:
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.alink = AsyncLink()
|
|
||||||
self.worker = asyncio.get_event_loop().run_in_executor(
|
|
||||||
None,
|
|
||||||
self.worker_thread,
|
|
||||||
self.alink.to_sync,
|
|
||||||
)
|
|
||||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||||
|
self.cache_lock = threading.Lock()
|
||||||
|
self.file_locks: dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
await self.alink.stop()
|
self.cache.close()
|
||||||
await self.worker
|
|
||||||
|
|
||||||
def worker_thread(self, slink):
|
@staticmethod
|
||||||
|
def _stat_size(path):
|
||||||
try:
|
try:
|
||||||
for req in slink:
|
return Path(path).stat().st_size
|
||||||
with req as (command, *args):
|
except FileNotFoundError:
|
||||||
if command == "upload":
|
return None
|
||||||
req.set_result(self.upload(*args))
|
|
||||||
elif command == "download":
|
|
||||||
req.set_result(self.download(*args))
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"Unhandled {command=} {args}")
|
|
||||||
finally:
|
|
||||||
self.cache.close()
|
|
||||||
|
|
||||||
def upload(self, name, pos, data, file_size):
|
def upload_info(self, name, pos, data, file_size):
|
||||||
name = filename.sanitize(name)
|
name = filename.sanitize(name)
|
||||||
f = self.cache[name]
|
with self.cache_lock:
|
||||||
f.write(pos, data, file_size=file_size)
|
f = self.cache[name]
|
||||||
return len(data)
|
lock = self.file_locks.setdefault(name, threading.Lock())
|
||||||
|
with lock:
|
||||||
def download(self, name, start, end):
|
size_before = self._stat_size(f.path)
|
||||||
name = filename.sanitize(name)
|
f.write(pos, data, file_size=file_size)
|
||||||
f = self.cache[name]
|
size_after = self._stat_size(f.path)
|
||||||
return f[start:end]
|
return {
|
||||||
|
"written": len(data),
|
||||||
|
"created": size_before is None,
|
||||||
|
"size_before": size_before,
|
||||||
|
"size_after": size_after,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import errno
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from urllib.parse import quote as url_quote
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
|
from sanic import Blueprint, HTTPResponse, empty, json
|
||||||
|
from sanic.exceptions import BadRequest, NotFound, SanicException
|
||||||
|
|
||||||
|
from cista import auth, config, sharefs, watching
|
||||||
|
from cista.api import fileserver
|
||||||
|
from cista.util import filename
|
||||||
|
from cista.util.diskspace import InsufficientStorageError
|
||||||
|
|
||||||
|
bp = Blueprint("fileserver", url_prefix="/files")
|
||||||
|
|
||||||
|
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
|
||||||
|
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||||
|
_FILE_CHUNK_SIZE = 1 << 20
|
||||||
|
|
||||||
|
_DAV_NS = "DAV:"
|
||||||
|
ET.register_namespace("D", _DAV_NS)
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_tag(name: str) -> str:
|
||||||
|
return f"{{{_DAV_NS}}}{name}"
|
||||||
|
|
||||||
|
|
||||||
|
@bp.on_request
|
||||||
|
async def verify_fileserver(request):
|
||||||
|
"""Verify access to file server routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/<name:path>")
|
||||||
|
async def upload_file_chunk(request, name):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
body = request.body
|
||||||
|
header = request.headers.get("content-range")
|
||||||
|
if header:
|
||||||
|
start, end, total = _parse_content_range(header, len(body))
|
||||||
|
else:
|
||||||
|
start = 0
|
||||||
|
end = len(body)
|
||||||
|
total = end
|
||||||
|
|
||||||
|
rel, path = _safe_relpath(name, request=request)
|
||||||
|
rel_name = rel.as_posix()
|
||||||
|
try:
|
||||||
|
upload_info = await asyncio.to_thread(
|
||||||
|
fileserver.upload_info,
|
||||||
|
rel_name,
|
||||||
|
start,
|
||||||
|
body,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
except InsufficientStorageError as e:
|
||||||
|
raise SanicException(str(e), status_code=507, quiet=True) from e
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise SanicException(
|
||||||
|
"No space left on device", status_code=507, quiet=True
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
|
extras = []
|
||||||
|
chunk_len = end - start
|
||||||
|
whole_file = start == 0 and end == total
|
||||||
|
if not whole_file:
|
||||||
|
start_mib = _to_mib_int(start)
|
||||||
|
chunk_mib = _to_mib_int(chunk_len)
|
||||||
|
# Keep range logs compact for fixed-size upload blocks.
|
||||||
|
if chunk_mib == 16:
|
||||||
|
extras.append(f"{start_mib}MiB")
|
||||||
|
else:
|
||||||
|
extras.append(f"{start_mib}+{chunk_mib}MiB")
|
||||||
|
if upload_info.get("created"):
|
||||||
|
extras.append(f"created {_to_mib_int(total)}MiB")
|
||||||
|
size_before = upload_info.get("size_before")
|
||||||
|
size_after = upload_info.get("size_after")
|
||||||
|
if size_before is not None and size_after is not None and size_before != size_after:
|
||||||
|
extras.append("resized")
|
||||||
|
request.ctx.log_extra = " ".join(extras) if extras else None
|
||||||
|
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
|
||||||
|
watching.notify_change(real_rel, *real_rel.parents)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"status": "ack",
|
||||||
|
"req": {
|
||||||
|
"name": rel_name,
|
||||||
|
"size": total,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/<name:path>")
|
||||||
|
async def delete_file(request, name):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
rel, path = _safe_relpath(name, request=request)
|
||||||
|
if not rel.parts:
|
||||||
|
raise BadRequest("Refusing to delete root folder")
|
||||||
|
|
||||||
|
def _delete():
|
||||||
|
if not path.exists():
|
||||||
|
raise NotFound(f"File not found: {name}")
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
await asyncio.to_thread(_delete)
|
||||||
|
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
|
||||||
|
watching.notify_change(real_rel, *real_rel.parents)
|
||||||
|
return empty(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<name:path>", methods=["MKCOL"])
|
||||||
|
async def create_folder(request, name):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
rel, path = _safe_relpath(name, request=request)
|
||||||
|
if not rel.parts:
|
||||||
|
raise BadRequest("Refusing to create root folder")
|
||||||
|
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
|
||||||
|
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
|
||||||
|
watching.notify_change(real_rel, *real_rel.parents)
|
||||||
|
return empty(status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/", name="post_root", strict_slashes=False)
|
||||||
|
@bp.post("/<name:path>", name="post_path")
|
||||||
|
async def copy_or_move(request, name=""):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
provided_args = set(request.args.keys())
|
||||||
|
if not provided_args:
|
||||||
|
raise BadRequest("No query arguments passed")
|
||||||
|
|
||||||
|
allowed_args = {"cp", "mv"}
|
||||||
|
unknown_args = sorted(provided_args - allowed_args)
|
||||||
|
if unknown_args:
|
||||||
|
raise BadRequest(f"Unknown query parameter(s): {', '.join(unknown_args)}")
|
||||||
|
|
||||||
|
mv_vals = request.args.getlist("mv")
|
||||||
|
cp_vals = request.args.getlist("cp")
|
||||||
|
|
||||||
|
mv_keys: list[str] = []
|
||||||
|
for value in mv_vals:
|
||||||
|
mv_keys.extend(k for k in value.split() if k)
|
||||||
|
|
||||||
|
cp_keys: list[str] = []
|
||||||
|
for value in cp_vals:
|
||||||
|
cp_keys.extend(k for k in value.split() if k)
|
||||||
|
|
||||||
|
if not mv_keys and not cp_keys:
|
||||||
|
raise BadRequest("No keys given")
|
||||||
|
|
||||||
|
dst_rel, dst_abs = _safe_relpath(name, request=request)
|
||||||
|
|
||||||
|
dst_exists = dst_abs.exists()
|
||||||
|
dst_is_dir = dst_exists and dst_abs.is_dir()
|
||||||
|
|
||||||
|
ordered_keys = cp_keys + mv_keys
|
||||||
|
key_paths = _get_key_paths(request, set(ordered_keys))
|
||||||
|
missing = [key for key in ordered_keys if key not in key_paths]
|
||||||
|
if missing:
|
||||||
|
raise NotFound("Files not found", context={"missing": missing})
|
||||||
|
|
||||||
|
# Validate target shape/type before mutating anything.
|
||||||
|
for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
||||||
|
if len(op_keys) > 1 and not dst_is_dir:
|
||||||
|
raise BadRequest(
|
||||||
|
"Destination must be an existing directory for multiple keys"
|
||||||
|
)
|
||||||
|
if not op_keys:
|
||||||
|
continue
|
||||||
|
if not dst_is_dir:
|
||||||
|
if not dst_rel.parts:
|
||||||
|
raise BadRequest("Destination file path is required")
|
||||||
|
parent_abs = dst_abs.parent
|
||||||
|
if not parent_abs.is_dir():
|
||||||
|
raise BadRequest("Destination parent folder does not exist")
|
||||||
|
if dst_exists and dst_abs.is_file():
|
||||||
|
for key in op_keys:
|
||||||
|
src_abs = _resolve_from_relpath(key_paths[key])
|
||||||
|
if src_abs.is_dir():
|
||||||
|
raise BadRequest(
|
||||||
|
"Cannot move/copy a directory to an existing file"
|
||||||
|
)
|
||||||
|
|
||||||
|
changed: set[PurePosixPath] = set()
|
||||||
|
completed: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
class _FileOpError(Exception):
|
||||||
|
def __init__(self, op_name: str, key: str, error: Exception):
|
||||||
|
self.op_name = op_name
|
||||||
|
self.key = key
|
||||||
|
self.error = error
|
||||||
|
super().__init__(str(error))
|
||||||
|
|
||||||
|
def _apply():
|
||||||
|
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
||||||
|
for key in op_keys:
|
||||||
|
try:
|
||||||
|
src_rel = key_paths[key]
|
||||||
|
src_abs = _resolve_from_relpath(src_rel, request=request)
|
||||||
|
|
||||||
|
if dst_is_dir:
|
||||||
|
dst_item_rel = (
|
||||||
|
dst_rel / src_rel.name
|
||||||
|
if dst_rel.parts
|
||||||
|
else PurePosixPath(src_rel.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dst_item_rel = dst_rel
|
||||||
|
|
||||||
|
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
|
||||||
|
|
||||||
|
if op_name == "mv":
|
||||||
|
# A no-op rename should still return success.
|
||||||
|
if src_abs != dst_item_abs:
|
||||||
|
shutil.move(src_abs, dst_item_abs)
|
||||||
|
changed.add(src_rel)
|
||||||
|
changed.add(src_rel.parent)
|
||||||
|
elif src_abs.is_dir():
|
||||||
|
shutil.copytree(
|
||||||
|
src_abs,
|
||||||
|
dst_item_abs,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
ignore_dangling_symlinks=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_abs, dst_item_abs)
|
||||||
|
|
||||||
|
changed.add(dst_item_rel)
|
||||||
|
changed.add(dst_item_rel.parent)
|
||||||
|
completed.append({"op": op_name, "key": key})
|
||||||
|
except Exception as e:
|
||||||
|
raise _FileOpError(op_name, key, e) from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_apply)
|
||||||
|
except _FileOpError as e:
|
||||||
|
raise BadRequest(
|
||||||
|
"File operation failed after partial progress",
|
||||||
|
context={
|
||||||
|
"failed_op": e.op_name,
|
||||||
|
"failed_key": e.key,
|
||||||
|
"error": str(e.error),
|
||||||
|
"completed": completed,
|
||||||
|
},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
notify_paths = [p for p in changed if p.parts]
|
||||||
|
if notify_paths:
|
||||||
|
real_notify_paths: list[PurePosixPath] = []
|
||||||
|
for p in notify_paths:
|
||||||
|
real_abs = _resolve_from_relpath(p, request=request)
|
||||||
|
real_notify_paths.append(
|
||||||
|
PurePosixPath(
|
||||||
|
real_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
watching.notify_change(*real_notify_paths)
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"status": "ack",
|
||||||
|
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<name:path>")
|
||||||
|
async def get_file(request, name=""):
|
||||||
|
return await _send_static_file(request, name, head_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.head("/<name:path>")
|
||||||
|
async def head_file(request, name=""):
|
||||||
|
return await _send_static_file(request, name, head_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
||||||
|
async def dav_options(request, name=""):
|
||||||
|
_ = request
|
||||||
|
_ = name
|
||||||
|
return HTTPResponse(
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
|
||||||
|
"DAV": "1",
|
||||||
|
"MS-Author-Via": "DAV",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
|
||||||
|
async def dav_propfind(request, name=""):
|
||||||
|
rel, path = _safe_relpath(name, request=request)
|
||||||
|
token = auth.request_share_token(request)
|
||||||
|
if token is not None and not rel.parts:
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
entries = [_propfind_entry(PurePosixPath(), base)]
|
||||||
|
depth = request.headers.get("depth", "1").strip()
|
||||||
|
if depth == "infinity":
|
||||||
|
return HTTPResponse(status=403)
|
||||||
|
if depth == "1":
|
||||||
|
for root in sharefs.build_share_roots(token):
|
||||||
|
child_abs = (base / root.real_rel).resolve()
|
||||||
|
if not child_abs.exists() or not child_abs.is_relative_to(base):
|
||||||
|
continue
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
entries.append(
|
||||||
|
_propfind_entry(PurePosixPath(root.alias), child_abs)
|
||||||
|
)
|
||||||
|
return HTTPResponse(
|
||||||
|
body=_build_propfind_xml(entries),
|
||||||
|
status=207,
|
||||||
|
content_type='application/xml; charset="utf-8"',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
raise NotFound(f"Not found: {name}")
|
||||||
|
depth = request.headers.get("depth", "1").strip()
|
||||||
|
if depth == "infinity":
|
||||||
|
return HTTPResponse(status=403)
|
||||||
|
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
|
||||||
|
return HTTPResponse(
|
||||||
|
body=_build_propfind_xml(entries),
|
||||||
|
status=207,
|
||||||
|
content_type='application/xml; charset="utf-8"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
|
||||||
|
async def dav_copy(request, name=""):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
|
||||||
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
raise BadRequest("Source and destination are the same")
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_copy():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
if src_abs.is_dir():
|
||||||
|
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_copy)
|
||||||
|
real_dst_rel = PurePosixPath(
|
||||||
|
dst_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
watching.notify_change(real_dst_rel, *real_dst_rel.parents)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
|
||||||
|
async def dav_move(request, name=""):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
|
||||||
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
return HTTPResponse(status=204)
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_move():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
shutil.move(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_move)
|
||||||
|
real_src_rel = PurePosixPath(
|
||||||
|
src_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
real_dst_rel = PurePosixPath(
|
||||||
|
dst_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
watching.notify_change(
|
||||||
|
real_src_rel, *real_src_rel.parents, real_dst_rel, *real_dst_rel.parents
|
||||||
|
)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||||
|
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||||
|
if m is None:
|
||||||
|
raise BadRequest("Invalid Content-Range format")
|
||||||
|
start, end_inclusive, total = (int(v) for v in m.groups())
|
||||||
|
if total <= 0:
|
||||||
|
raise BadRequest("Invalid Content-Range total size")
|
||||||
|
if start > end_inclusive:
|
||||||
|
raise BadRequest("Invalid Content-Range range")
|
||||||
|
if end_inclusive >= total:
|
||||||
|
raise BadRequest("Content-Range exceeds total size")
|
||||||
|
expected_len = end_inclusive - start + 1
|
||||||
|
if expected_len != body_len:
|
||||||
|
raise BadRequest(
|
||||||
|
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
|
||||||
|
)
|
||||||
|
return start, end_inclusive + 1, total
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mib_int(value_bytes: int) -> int:
|
||||||
|
return round(value_bytes / (1 << 20))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relpath(path: str, *, request=None) -> tuple[PurePosixPath, Path]:
|
||||||
|
"""Resolve a user path under storage root and enforce containment."""
|
||||||
|
token = auth.request_share_token(request) if request is not None else None
|
||||||
|
if token is not None:
|
||||||
|
vrel, _rrel, resolved, is_root = sharefs.resolve_virtual_path(token, path)
|
||||||
|
if is_root:
|
||||||
|
return vrel, config.config.path.resolve()
|
||||||
|
return vrel, resolved
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
try:
|
||||||
|
sanitized = filename.sanitize(unquote(path))
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(f"Invalid path: {e}") from e
|
||||||
|
resolved = (base / sanitized).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
rel = PurePosixPath(resolved.relative_to(base).as_posix())
|
||||||
|
return rel, resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_from_relpath(rel: PurePosixPath, *, request=None) -> Path:
|
||||||
|
"""Resolve a relative path under storage root and enforce containment."""
|
||||||
|
token = auth.request_share_token(request) if request is not None else None
|
||||||
|
if token is not None:
|
||||||
|
return sharefs.resolve_virtual_rel_to_real(token, rel)
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
resolved = (base / rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_static_file(request, name: str, *, head_only: bool):
|
||||||
|
_, path = _safe_relpath(name, request=request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = await asyncio.to_thread(path.stat)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotFound(f"File not found: {name}") from None
|
||||||
|
if path.is_dir():
|
||||||
|
raise NotFound(f"Not a file: {name}")
|
||||||
|
|
||||||
|
size = st.st_size
|
||||||
|
start = 0
|
||||||
|
end_excl = size
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
if range_header is not None:
|
||||||
|
parsed = _parse_range_header(range_header, size)
|
||||||
|
if parsed is None:
|
||||||
|
return empty(
|
||||||
|
status=416,
|
||||||
|
headers={
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"content-range": f"bytes */{size}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
start, end_excl = parsed
|
||||||
|
status = 206
|
||||||
|
|
||||||
|
length = end_excl - start
|
||||||
|
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
headers = {
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-length": str(length),
|
||||||
|
"content-type": mime,
|
||||||
|
"last-modified": format_date_time(st.st_mtime),
|
||||||
|
}
|
||||||
|
if status == 206:
|
||||||
|
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
|
||||||
|
|
||||||
|
if head_only:
|
||||||
|
return empty(status=status, headers=headers)
|
||||||
|
|
||||||
|
res = await request.respond(status=status, headers=headers)
|
||||||
|
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
pos = start
|
||||||
|
while pos < end_excl:
|
||||||
|
chunk = await asyncio.to_thread(
|
||||||
|
os.pread,
|
||||||
|
fd,
|
||||||
|
min(_FILE_CHUNK_SIZE, end_excl - pos),
|
||||||
|
pos,
|
||||||
|
)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
pos += len(chunk)
|
||||||
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(os.close, fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
|
||||||
|
value = header.strip()
|
||||||
|
if "," in value:
|
||||||
|
return None
|
||||||
|
m = _RANGE_RE.fullmatch(value)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_s, end_s = m.groups()
|
||||||
|
if not start_s and not end_s:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if start_s:
|
||||||
|
start = int(start_s)
|
||||||
|
if start >= size:
|
||||||
|
return None
|
||||||
|
end_inclusive = int(end_s) if end_s else (size - 1)
|
||||||
|
if end_inclusive < start:
|
||||||
|
return None
|
||||||
|
end_inclusive = min(end_inclusive, size - 1)
|
||||||
|
return start, end_inclusive + 1
|
||||||
|
|
||||||
|
suffix_len = int(end_s)
|
||||||
|
if suffix_len <= 0:
|
||||||
|
return None
|
||||||
|
if suffix_len >= size:
|
||||||
|
return 0, size
|
||||||
|
start = size - suffix_len
|
||||||
|
return start, size
|
||||||
|
|
||||||
|
|
||||||
|
def _get_key_paths(request, wanted: set[str]) -> dict[str, PurePosixPath]:
|
||||||
|
"""Map file keys to their current relative filesystem paths."""
|
||||||
|
token = auth.request_share_token(request)
|
||||||
|
if token is not None:
|
||||||
|
return sharefs.key_paths_for_token(token, wanted)
|
||||||
|
|
||||||
|
loc = PurePosixPath()
|
||||||
|
ret: dict[str, PurePosixPath] = {}
|
||||||
|
with watching.state.lock:
|
||||||
|
root = watching.state.root
|
||||||
|
for f in root:
|
||||||
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
|
if f.key in wanted and f.key not in ret:
|
||||||
|
ret[f.key] = loc
|
||||||
|
if len(ret) == len(wanted):
|
||||||
|
break
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WebDAV helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_webdav_destination(
|
||||||
|
dest_header: str, *, request=None
|
||||||
|
) -> tuple[PurePosixPath, Path]:
|
||||||
|
"""Parse a WebDAV Destination header and resolve it to a storage path."""
|
||||||
|
parsed = urlparse(dest_header)
|
||||||
|
raw_path = parsed.path # still percent-encoded
|
||||||
|
prefix = "/files"
|
||||||
|
if raw_path in (prefix, prefix + "/"):
|
||||||
|
rel_str = ""
|
||||||
|
elif raw_path.startswith(prefix + "/"):
|
||||||
|
rel_str = raw_path[len(prefix) + 1 :]
|
||||||
|
else:
|
||||||
|
raise BadRequest("Destination must be within /files")
|
||||||
|
return _safe_relpath(rel_str, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
|
||||||
|
"""Build a DAV href from a storage-relative path."""
|
||||||
|
parts = rel.parts
|
||||||
|
if not parts:
|
||||||
|
return "/files/"
|
||||||
|
encoded = "/".join(url_quote(p, safe="") for p in parts)
|
||||||
|
href = f"/files/{encoded}"
|
||||||
|
return href + "/" if is_dir else href
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_xml(element: ET.Element) -> bytes:
|
||||||
|
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
|
||||||
|
return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
|
||||||
|
element, encoding="unicode"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
|
||||||
|
entries = [_propfind_entry(rel, path)]
|
||||||
|
if depth == "1" and path.is_dir():
|
||||||
|
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
|
||||||
|
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
entries.append(_propfind_entry(child_rel, child))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
|
||||||
|
st = path.stat()
|
||||||
|
is_dir = path.is_dir()
|
||||||
|
return {
|
||||||
|
"href": _rel_to_href(rel, is_dir=is_dir),
|
||||||
|
"name": rel.parts[-1] if rel.parts else "",
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size": st.st_size,
|
||||||
|
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
|
||||||
|
"content_type": mimetypes.guess_type(path.name)[0]
|
||||||
|
or "application/octet-stream",
|
||||||
|
"last_modified": format_date_time(st.st_mtime),
|
||||||
|
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_propfind_xml(entries: list[dict]) -> bytes:
|
||||||
|
multistatus = ET.Element(_dav_tag("multistatus"))
|
||||||
|
for e in entries:
|
||||||
|
response = ET.SubElement(multistatus, _dav_tag("response"))
|
||||||
|
ET.SubElement(response, _dav_tag("href")).text = e["href"]
|
||||||
|
propstat = ET.SubElement(response, _dav_tag("propstat"))
|
||||||
|
prop = ET.SubElement(propstat, _dav_tag("prop"))
|
||||||
|
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
|
||||||
|
if e["is_dir"]:
|
||||||
|
ET.SubElement(rt, _dav_tag("collection"))
|
||||||
|
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
|
||||||
|
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
|
||||||
|
if not e["is_dir"]:
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
|
||||||
|
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
|
||||||
|
return _dav_xml(multistatus)
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""OnlyOffice Document Server integration for office document preview.
|
||||||
|
|
||||||
|
Provides server-side conversion of office documents to PNG via the
|
||||||
|
OnlyOffice Document Server /ConvertService.ashx API. The resulting PNG
|
||||||
|
is passed through pyvips for AVIF compression.
|
||||||
|
|
||||||
|
Environment requirements:
|
||||||
|
- OnlyOffice Document Server must be running and reachable.
|
||||||
|
- If Document Server runs in Docker, the callback host IP must be
|
||||||
|
reachable from the container (usually the docker bridge IP).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_httpx_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onlyoffice_url() -> str:
|
||||||
|
return os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_secret() -> str:
|
||||||
|
return (
|
||||||
|
os.environ.get("ONLYOFFICE_JWT_SECRET")
|
||||||
|
or config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_callback_host() -> str:
|
||||||
|
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
|
||||||
|
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
||||||
|
return host
|
||||||
|
# Try to auto-detect docker bridge IP
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "inet " in line:
|
||||||
|
parts = line.strip().split()
|
||||||
|
addr_part = parts[1] # e.g. 172.17.0.1/16
|
||||||
|
return addr_part.split("/")[0]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to auto-detect docker bridge IP")
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async HTTP client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_httpx_client() -> httpx.AsyncClient:
|
||||||
|
"""Return the shared async HTTP client for OnlyOffice requests."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is None:
|
||||||
|
_httpx_client = httpx.AsyncClient()
|
||||||
|
return _httpx_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_oo_client() -> None:
|
||||||
|
"""Close the shared async HTTP client."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is not None:
|
||||||
|
await _httpx_client.aclose()
|
||||||
|
_httpx_client = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Availability check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_status() -> tuple[bool, bool, str | None]:
|
||||||
|
"""Return (ok, responded, detail) for a lightweight reachability probe."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as resp: # noqa: S310
|
||||||
|
status = resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status = e.code
|
||||||
|
except Exception:
|
||||||
|
return False, False, None
|
||||||
|
|
||||||
|
if status in (200, 405):
|
||||||
|
return True, True, None
|
||||||
|
if status >= 500:
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_reachable_info() -> None:
|
||||||
|
"""Log info on success, warning on responded probe errors, silent on no-response."""
|
||||||
|
ok, responded, detail = _probe_status()
|
||||||
|
if ok:
|
||||||
|
logger.info("Using OnlyOffice document server at %s", _get_onlyoffice_url())
|
||||||
|
elif responded:
|
||||||
|
suffix = f": {detail}" if detail else ""
|
||||||
|
logger.warning("OnlyOffice probe failed%s", suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_docker(confdir: Path | None = None) -> int:
|
||||||
|
"""Build and run the patched OnlyOffice Docker image."""
|
||||||
|
if confdir is not None:
|
||||||
|
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||||
|
config.init_confdir()
|
||||||
|
if config.conffile.exists():
|
||||||
|
config.load_config()
|
||||||
|
else:
|
||||||
|
config.update_config(
|
||||||
|
{
|
||||||
|
"listen": ":8989",
|
||||||
|
"path": Path.home() / "Downloads",
|
||||||
|
"public": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
docker_dir = Path(__file__).parent / "docker"
|
||||||
|
if not docker_dir.is_dir():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Docker files not found at {docker_dir}. Is the package installed correctly?"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Building OnlyOffice image")
|
||||||
|
build_cmd = ["docker", "build", "-t", "onlyoffice-cista", str(docker_dir)]
|
||||||
|
logger.info("%s", " ".join(build_cmd))
|
||||||
|
result = subprocess.run(build_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to build OnlyOffice image")
|
||||||
|
|
||||||
|
logger.info("Starting OnlyOffice container")
|
||||||
|
run_cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"-p",
|
||||||
|
"8988:80",
|
||||||
|
"-e",
|
||||||
|
f"JWT_SECRET={secret}",
|
||||||
|
"-e",
|
||||||
|
"WORKERS=8",
|
||||||
|
"--name",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
]
|
||||||
|
logger.info("%s", " ".join(run_cmd))
|
||||||
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to start OnlyOffice container")
|
||||||
|
logger.info("OnlyOffice is running on http://localhost:8988")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_async(request_timeout: float = 2.0) -> bool:
|
||||||
|
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=request_timeout)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return response.status_code in (200, 405)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_available_cache: tuple[bool, float] | None = None
|
||||||
|
OO_AVAILABILITY_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_cached() -> bool:
|
||||||
|
"""Return cached OnlyOffice availability, refreshed every 30 seconds."""
|
||||||
|
global _oo_available_cache
|
||||||
|
now = perf_counter()
|
||||||
|
if _oo_available_cache is not None:
|
||||||
|
result, timestamp = _oo_available_cache
|
||||||
|
if now - timestamp < OO_AVAILABILITY_CACHE_TTL:
|
||||||
|
return result
|
||||||
|
result = await is_available_async()
|
||||||
|
_oo_available_cache = (result, now)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Temporary HTTP server so OnlyOffice can download the file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("0.0.0.0", 0)) # noqa: S104
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_file_temporarily(file_path: Path):
|
||||||
|
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
|
||||||
|
directory = str(file_path.parent)
|
||||||
|
filename = file_path.name
|
||||||
|
port = _get_free_port()
|
||||||
|
|
||||||
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
|
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
host = _get_callback_host()
|
||||||
|
url = f"http://{host}:{port}/{quote(filename)}"
|
||||||
|
return url, httpd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OnlyOffice conversion client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_jwt_token(payload: dict) -> str | None:
|
||||||
|
secret = _get_jwt_secret()
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
|
||||||
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
|
"""
|
||||||
|
oo_url = _get_onlyoffice_url().rstrip("/")
|
||||||
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
|
||||||
|
# Start temporary HTTP server so OnlyOffice can fetch the file
|
||||||
|
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
||||||
|
try:
|
||||||
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
|
payload = {
|
||||||
|
"async": False,
|
||||||
|
"filetype": suffix,
|
||||||
|
"key": f"cista_{(await asyncio.to_thread(file_path.stat)).st_mtime_ns}",
|
||||||
|
"outputtype": "png",
|
||||||
|
"title": file_path.name,
|
||||||
|
"url": doc_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
token = _build_jwt_token(payload)
|
||||||
|
if token:
|
||||||
|
# Conversion API expects JWT in request body when token checks are enabled.
|
||||||
|
payload["token"] = token
|
||||||
|
headers["Authorization"] = token
|
||||||
|
|
||||||
|
t_start = perf_counter()
|
||||||
|
response = await client.post(
|
||||||
|
convert_url,
|
||||||
|
content=json.dumps(payload).encode(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=request_timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.content
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
# Parse XML response
|
||||||
|
text = body.decode("utf-8", errors="replace")
|
||||||
|
if "<Error>" in text:
|
||||||
|
code = "unknown"
|
||||||
|
if "<Error>" in text and "</Error>" in text:
|
||||||
|
code = text.split("<Error>")[1].split("</Error>")[0]
|
||||||
|
raise RuntimeError(f"OnlyOffice conversion error: {code}")
|
||||||
|
|
||||||
|
if "<FileUrl>" not in text:
|
||||||
|
raise RuntimeError("OnlyOffice response did not contain FileUrl")
|
||||||
|
|
||||||
|
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
||||||
|
file_url = file_url.replace("&", "&")
|
||||||
|
|
||||||
|
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
||||||
|
|
||||||
|
# Download converted PNG
|
||||||
|
png_response = await client.get(file_url, timeout=request_timeout)
|
||||||
|
png_response.raise_for_status()
|
||||||
|
return png_response.content
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
@@ -1,117 +1,746 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import gc
|
import contextlib
|
||||||
import io
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import PurePosixPath
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from multiprocessing import cpu_count
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import av
|
import httpx
|
||||||
import av.datasets
|
import msgspec
|
||||||
import fitz # PyMuPDF
|
from blake3 import blake3
|
||||||
from PIL import Image
|
from sanic import Blueprint, empty, raw, redirect
|
||||||
from sanic import Blueprint, empty, raw
|
|
||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import config
|
from cista import auth, config, onlyoffice, sharefs, watching
|
||||||
|
from cista.fileio import fuid
|
||||||
|
from cista.preview_worker import (
|
||||||
|
DOC_PREVIEW_SUFFIXES,
|
||||||
|
OFFICE_PREVIEW_SUFFIXES,
|
||||||
|
PreviewRequest,
|
||||||
|
PreviewResponse,
|
||||||
|
)
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
|
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CachedPreview:
|
||||||
|
"""Cached preview with headers and body."""
|
||||||
|
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewCache:
|
||||||
|
"""Thread-safe LRU cache for preview responses."""
|
||||||
|
|
||||||
|
def __init__(self, capacity: int = 500):
|
||||||
|
self.capacity = capacity
|
||||||
|
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> CachedPreview | None:
|
||||||
|
"""Get cached preview, moving it to end (most recently used)."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: CachedPreview) -> None:
|
||||||
|
"""Cache preview, evicting oldest if at capacity."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
else:
|
||||||
|
if len(self._cache) >= self.capacity:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
self._cache[key] = value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._cache)
|
||||||
|
|
||||||
|
|
||||||
|
# Global preview cache instance
|
||||||
|
_preview_cache = PreviewCache(capacity=500)
|
||||||
|
|
||||||
|
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
||||||
|
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||||
|
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
|
||||||
|
WORKER_RESPAWN_DELAY = 1.0 # initial delay before retrying a failed worker spawn
|
||||||
|
WORKER_RESPAWN_DELAY_MAX = 30.0
|
||||||
|
_active_procs: set[asyncio.subprocess.Process] = set()
|
||||||
|
_preview_pool = None
|
||||||
|
_preview_pool_lock = asyncio.Lock()
|
||||||
|
AVIF_FAST_EFFORT = 0
|
||||||
|
WORKER_CHECKSUM_BYTES = 32
|
||||||
|
WORKER_MAX_JSON_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerChecksumError(Exception):
|
||||||
|
"""Raised when worker response checksum does not match the packet."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerProtocolError(Exception):
|
||||||
|
"""Raised when worker response packet is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
class _PreviewWorker:
|
||||||
|
def __init__(self, proc: asyncio.subprocess.Process):
|
||||||
|
self.proc = proc
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
quality: int,
|
||||||
|
maxsize: int,
|
||||||
|
maxzoom: float,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
|
if self.proc.returncode is not None:
|
||||||
|
raise WorkerProtocolError("worker already exited")
|
||||||
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
|
meta = msgspec.json.encode(
|
||||||
|
PreviewRequest(
|
||||||
|
path=str(filepath),
|
||||||
|
quality=quality,
|
||||||
|
maxsize=maxsize,
|
||||||
|
maxzoom=maxzoom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = data or b""
|
||||||
|
packet = struct.pack("<II", len(meta), len(payload)) + meta + payload
|
||||||
|
self.proc.stdin.write(packet)
|
||||||
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
|
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||||
|
header = await self.proc.stdout.readexactly(8)
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
if json_size > WORKER_MAX_JSON_BYTES:
|
||||||
|
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
|
||||||
|
meta_raw = await self.proc.stdout.readexactly(json_size)
|
||||||
|
payload = await self.proc.stdout.readexactly(data_size)
|
||||||
|
packet = header + meta_raw + payload
|
||||||
|
if blake3(packet).digest() != checksum:
|
||||||
|
raise WorkerChecksumError("worker checksum mismatch")
|
||||||
|
|
||||||
|
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
||||||
|
if not resp.ok:
|
||||||
|
raise PreviewError(
|
||||||
|
resp.error or "preview worker error",
|
||||||
|
stderr=resp.stderr,
|
||||||
|
backend=resp.backend,
|
||||||
|
)
|
||||||
|
return payload or None, resp
|
||||||
|
|
||||||
|
async def kill(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.proc.returncode is None:
|
||||||
|
# Safe to hard-kill: the worker is stateless per request.
|
||||||
|
# proc.wait() must not be awaited unaided: if a pipe
|
||||||
|
# transport is flow-control paused (e.g. an undrained stderr
|
||||||
|
# pipe), asyncio may never resolve wait() even after SIGKILL,
|
||||||
|
# which would permanently wedge the calling dispatcher.
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
self.proc.kill()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.proc.wait(), timeout=WORKER_KILL_GRACE)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"Preview worker pid=%s not reaped within %ds of kill",
|
||||||
|
self.proc.pid,
|
||||||
|
int(WORKER_KILL_GRACE),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_active_procs.discard(self.proc)
|
||||||
|
|
||||||
|
|
||||||
|
class _PreviewWorkerPool:
|
||||||
|
def __init__(self, size: int):
|
||||||
|
self.size = size
|
||||||
|
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
|
||||||
|
self._pending: asyncio.PriorityQueue[tuple[int, int, asyncio.Future, tuple]] = (
|
||||||
|
asyncio.PriorityQueue()
|
||||||
|
)
|
||||||
|
self._workers: set[_PreviewWorker] = set()
|
||||||
|
self._dispatchers: list[asyncio.Task] = []
|
||||||
|
self._seq = 0
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def _spawn_worker(self) -> _PreviewWorker:
|
||||||
|
# stderr is inherited, not piped: a piped stderr that nobody drains
|
||||||
|
# eventually fills its OS buffer, blocking the worker mid-request,
|
||||||
|
# and its flow-control-paused transport makes proc.wait() hang even
|
||||||
|
# after kill() — together this used to permanently wedge the pool.
|
||||||
|
# Inheriting sends worker diagnostics straight to the server log.
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"cista.preview_worker",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=None,
|
||||||
|
)
|
||||||
|
_active_procs.add(proc)
|
||||||
|
try:
|
||||||
|
ready = await asyncio.wait_for(proc.stdout.readexactly(1), timeout=30.0)
|
||||||
|
except TimeoutError as err:
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
proc.kill()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await proc.wait()
|
||||||
|
raise WorkerProtocolError(
|
||||||
|
"preview worker failed to become ready"
|
||||||
|
" (worker stderr goes to the server log)"
|
||||||
|
) from err
|
||||||
|
except asyncio.IncompleteReadError as err:
|
||||||
|
raise WorkerProtocolError(
|
||||||
|
"preview worker exited before signalling readiness"
|
||||||
|
" (worker stderr goes to the server log)"
|
||||||
|
) from err
|
||||||
|
if ready != b"\x01":
|
||||||
|
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
||||||
|
return _PreviewWorker(proc)
|
||||||
|
|
||||||
|
async def _add_worker(self) -> None:
|
||||||
|
worker = await self._spawn_worker()
|
||||||
|
self._workers.add(worker)
|
||||||
|
await self._idle.put(worker)
|
||||||
|
|
||||||
|
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||||
|
self._workers.discard(worker)
|
||||||
|
try:
|
||||||
|
await worker.kill()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to kill preview worker pid=%s", worker.proc.pid)
|
||||||
|
# Keep retrying until a replacement is up: a pool that silently
|
||||||
|
# shrinks degrades all preview traffic to timeouts.
|
||||||
|
delay = WORKER_RESPAWN_DELAY
|
||||||
|
while not self._closed:
|
||||||
|
try:
|
||||||
|
await self._add_worker()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
|
||||||
|
len(self._workers),
|
||||||
|
self.size,
|
||||||
|
int(delay),
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _dispatch_loop(self) -> None:
|
||||||
|
# Nothing may escape the loop body: a dispatcher that dies silently
|
||||||
|
# permanently shrinks pool capacity and degrades all preview
|
||||||
|
# traffic to timeouts.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._dispatch_one()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Preview dispatcher error; continuing")
|
||||||
|
|
||||||
|
async def _dispatch_one(self) -> None:
|
||||||
|
_priority, _seq, future, args = await self._pending.get()
|
||||||
|
|
||||||
|
if future.cancelled():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker unavailable (%ds) for %s",
|
||||||
|
int(PREVIEW_TIMEOUT),
|
||||||
|
args[0].name,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
args[0].name,
|
||||||
|
backend=_expected_preview_backend(args[0]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath = args[0]
|
||||||
|
replace = False
|
||||||
|
try:
|
||||||
|
out, resp = await asyncio.wait_for(
|
||||||
|
worker.request(*args),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result((out, resp))
|
||||||
|
except TimeoutError:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker pid=%s timed out (%ds) on %s; replacing it",
|
||||||
|
worker.proc.pid,
|
||||||
|
int(PREVIEW_TIMEOUT),
|
||||||
|
filepath.name,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
filepath.name,
|
||||||
|
backend=_expected_preview_backend(filepath),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except WorkerChecksumError:
|
||||||
|
replace = True
|
||||||
|
logger.error(
|
||||||
|
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
|
||||||
|
filepath.name,
|
||||||
|
worker.proc.pid,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||||
|
)
|
||||||
|
except PreviewError as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
except (
|
||||||
|
WorkerProtocolError,
|
||||||
|
asyncio.IncompleteReadError,
|
||||||
|
BrokenPipeError,
|
||||||
|
ConnectionResetError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
msgspec.DecodeError,
|
||||||
|
) as e:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker pid=%s protocol failure for %s: %s",
|
||||||
|
worker.proc.pid,
|
||||||
|
filepath.name,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
replace = True
|
||||||
|
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if replace:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
elif worker.proc.returncode is None:
|
||||||
|
await self._idle.put(worker)
|
||||||
|
else:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
workers = await asyncio.gather(
|
||||||
|
*(self._spawn_worker() for _ in range(self.size))
|
||||||
|
)
|
||||||
|
for worker in workers:
|
||||||
|
self._workers.add(worker)
|
||||||
|
await self._idle.put(worker)
|
||||||
|
for _ in range(self.size):
|
||||||
|
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
quality: int,
|
||||||
|
maxsize: int,
|
||||||
|
maxzoom: float,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
|
if self._closed:
|
||||||
|
raise PreviewError("preview worker pool closed")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
future = loop.create_future()
|
||||||
|
self._seq += 1
|
||||||
|
await self._pending.put(
|
||||||
|
(
|
||||||
|
_preview_job_priority(filepath),
|
||||||
|
self._seq,
|
||||||
|
future,
|
||||||
|
(filepath, quality, maxsize, maxzoom, data),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
for task in self._dispatchers:
|
||||||
|
task.cancel()
|
||||||
|
if self._dispatchers:
|
||||||
|
await asyncio.gather(*self._dispatchers, return_exceptions=True)
|
||||||
|
self._dispatchers.clear()
|
||||||
|
workers = list(self._workers)
|
||||||
|
self._workers.clear()
|
||||||
|
while not self._pending.empty():
|
||||||
|
try:
|
||||||
|
_priority, _seq, future, _args = self._pending.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(PreviewError("preview worker pool closed"))
|
||||||
|
while not self._idle.empty():
|
||||||
|
try:
|
||||||
|
self._idle.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
await asyncio.gather(
|
||||||
|
*(worker.kill() for worker in workers), return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_preview_workers() -> None:
|
||||||
|
"""Warm up persistent preview workers during server startup."""
|
||||||
|
global _preview_pool
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
|
||||||
|
await pool.start()
|
||||||
|
_preview_pool = pool
|
||||||
|
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_preview_workers() -> None:
|
||||||
|
"""Kill persistent preview workers (called during server shutdown)."""
|
||||||
|
global _preview_pool
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
pool = _preview_pool
|
||||||
|
_preview_pool = None
|
||||||
|
if pool is not None:
|
||||||
|
await pool.close()
|
||||||
|
if not _active_procs:
|
||||||
|
return
|
||||||
|
for proc in list(_active_procs):
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
proc.kill()
|
||||||
|
await asyncio.gather(
|
||||||
|
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
|
||||||
|
)
|
||||||
|
_active_procs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.on_request
|
||||||
|
async def verify_preview(request):
|
||||||
|
"""Verify access to preview routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewTimeoutError(Exception):
|
||||||
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, backend: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewError(Exception):
|
||||||
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
stderr: str | None = None,
|
||||||
|
backend: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.stderr = stderr
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
# Max concurrent OnlyOffice conversion requests. OO has its own queue;
|
||||||
|
# we must not flood it. This is intentionally small.
|
||||||
|
OO_MAX_CONCURRENT = PREVIEW_WORKERS
|
||||||
|
|
||||||
|
|
||||||
|
class OOConversionManager:
|
||||||
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
||||||
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
|
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
||||||
|
stat = await asyncio.to_thread(filepath.stat)
|
||||||
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._in_flight:
|
||||||
|
future = self._in_flight[key]
|
||||||
|
else:
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._in_flight[key] = future
|
||||||
|
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _do_convert(
|
||||||
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with self._semaphore:
|
||||||
|
png_bytes = await onlyoffice.convert_to_png_async(
|
||||||
|
filepath, request_timeout=5.0
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
else:
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(png_bytes)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_manager: OOConversionManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_oo_manager() -> OOConversionManager:
|
||||||
|
"""Return the singleton OOConversionManager."""
|
||||||
|
global _oo_manager
|
||||||
|
if _oo_manager is None:
|
||||||
|
_oo_manager = OOConversionManager(max_concurrent=OO_MAX_CONCURRENT)
|
||||||
|
return _oo_manager
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_office_preview(
|
||||||
|
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
|
||||||
|
manager = get_oo_manager()
|
||||||
|
t_oo_start = perf_counter()
|
||||||
|
png_bytes = await manager.convert(filepath)
|
||||||
|
t_oo_end = perf_counter()
|
||||||
|
|
||||||
|
img, resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom, data=png_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp is not None:
|
||||||
|
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
|
||||||
|
if resp.timings:
|
||||||
|
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
||||||
|
return img, resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_preview_process(
|
||||||
|
filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Run preview request in a persistent worker process."""
|
||||||
|
await start_preview_workers()
|
||||||
|
if _preview_pool is None:
|
||||||
|
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
||||||
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
||||||
|
|
||||||
|
|
||||||
|
def _onlyoffice_error_short_text(detail: str) -> str:
|
||||||
|
if detail.startswith("OnlyOffice conversion error:"):
|
||||||
|
code = detail.rsplit(":", 1)[-1].strip()
|
||||||
|
return {
|
||||||
|
"-8": "onlyoffice jwt error",
|
||||||
|
"-4": "onlyoffice input error",
|
||||||
|
"-2": "onlyoffice timeout error",
|
||||||
|
"-1": "onlyoffice unknown error",
|
||||||
|
}.get(code, f"onlyoffice {code} error")
|
||||||
|
if "OnlyOffice response did not contain FileUrl" in detail:
|
||||||
|
return "onlyoffice no-fileurl error"
|
||||||
|
return "onlyoffice error"
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_job_priority(path) -> int:
|
||||||
|
"""Return priority for preview job (lower=higher priority).
|
||||||
|
|
||||||
|
Priority order: images (0) < video (1) < PDF (2) < office (3) < unknown (4)
|
||||||
|
"""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return 2
|
||||||
|
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return 3
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
return 0
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
return 1
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_preview_backend(path: Path) -> str:
|
||||||
|
"""Best-effort backend label used for timeout/access logging."""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return "onlyoffice"
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return "pdf"
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
return "video"
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
return "pyvips"
|
||||||
|
return "preview"
|
||||||
|
|
||||||
|
|
||||||
|
def is_previewable_path(path) -> bool:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return True
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if not mime_type:
|
||||||
|
return False
|
||||||
|
return mime_type.startswith(("image/", "video/"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<path:path>")
|
@bp.get("/<path:path>")
|
||||||
async def preview(req, path):
|
async def preview(req, path):
|
||||||
"""Preview a file"""
|
"""Preview a file"""
|
||||||
maxsize = int(req.args.get("px", 1024))
|
maxsize = int(req.args.get("px", 1024))
|
||||||
maxzoom = float(req.args.get("zoom", 2.0))
|
maxzoom = float(req.args.get("zoom", 2.0))
|
||||||
quality = int(req.args.get("q", 40))
|
quality = int(req.args.get("q", 60))
|
||||||
rel = PurePosixPath(sanitize(unquote(path)))
|
share_token = auth.request_share_token(req)
|
||||||
path = config.config.path / rel
|
if share_token is not None:
|
||||||
stat = path.lstat()
|
rel, _real_rel, filepath, is_root = sharefs.resolve_virtual_path(
|
||||||
|
share_token, path
|
||||||
|
)
|
||||||
|
if is_root:
|
||||||
|
raise NotFound from None
|
||||||
|
else:
|
||||||
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
|
filepath = config.config.path / rel
|
||||||
|
try:
|
||||||
|
stat = filepath.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotFound from None
|
||||||
|
|
||||||
|
if not is_previewable_path(filepath):
|
||||||
|
return empty(415)
|
||||||
|
|
||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
savename = PurePosixPath(path.name).with_suffix(".webp")
|
|
||||||
|
if req.headers.if_none_match == etag:
|
||||||
|
# The client has it cached, respond 304 Not Modified
|
||||||
|
return empty(304, headers={"etag": etag})
|
||||||
|
|
||||||
|
# Check in-memory cache first (includes headers)
|
||||||
|
cached = _preview_cache.get(etag)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"Preview cache hit: {rel}")
|
||||||
|
return raw(cached.body, headers=cached.headers)
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
try:
|
||||||
|
if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_generate_office_preview(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_run_preview_process(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
|
||||||
|
return empty(503)
|
||||||
|
except PreviewTimeoutError as e:
|
||||||
|
req.ctx.log_extra = (
|
||||||
|
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
|
||||||
|
)
|
||||||
|
return empty(503)
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
req.ctx.log_extra = "onlyoffice N/A"
|
||||||
|
return empty(503)
|
||||||
|
except httpx.RequestError:
|
||||||
|
req.ctx.log_extra = "onlyoffice N/A"
|
||||||
|
return empty(503)
|
||||||
|
except RuntimeError as e:
|
||||||
|
detail = str(e)
|
||||||
|
if detail.startswith("OnlyOffice"):
|
||||||
|
req.ctx.log_extra = _onlyoffice_error_short_text(detail)
|
||||||
|
return empty(503)
|
||||||
|
raise
|
||||||
|
except PreviewError as e:
|
||||||
|
if e.backend:
|
||||||
|
req.ctx.log_extra = e.backend
|
||||||
|
detail = str(e)
|
||||||
|
if detail == "preview worker error" and e.stderr:
|
||||||
|
captured = e.stderr.strip()
|
||||||
|
if captured:
|
||||||
|
detail = captured.splitlines()[0]
|
||||||
|
logger.error("%s preview: %s", filepath, detail)
|
||||||
|
return empty(422)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
req.ctx.log_extra = "preview cancelled"
|
||||||
|
return empty(503)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled preview error for %s", filepath)
|
||||||
|
return empty(500)
|
||||||
|
if preview_resp and preview_resp.backend:
|
||||||
|
if preview_resp.timings:
|
||||||
|
timing_detail = "/".join(
|
||||||
|
str(round(value)) for value in preview_resp.timings
|
||||||
|
)
|
||||||
|
req.ctx.log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||||
|
else:
|
||||||
|
req.ctx.log_extra = preview_resp.backend
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Store aspect ratio if the worker returned dimensions
|
||||||
|
if preview_resp and preview_resp.width and preview_resp.height:
|
||||||
|
ar = round(preview_resp.height / preview_resp.width, 2)
|
||||||
|
fuid_str = fuid(stat)
|
||||||
|
watching.notify_ar(fuid_str, ar)
|
||||||
|
|
||||||
|
# Build headers and cache the full response
|
||||||
|
preview_mime = (
|
||||||
|
preview_resp.mime
|
||||||
|
if preview_resp is not None and preview_resp.mime is not None
|
||||||
|
else "image/avif"
|
||||||
|
)
|
||||||
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": format_date_time(stat.st_mtime),
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
"cache-control": "max-age=604800, immutable"
|
"cache-control": "max-age=604800, immutable"
|
||||||
+ ("" if config.config.public else ", private"),
|
+ ("" if config.config.public else ", private"),
|
||||||
"content-type": "image/webp",
|
"content-type": preview_mime,
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
if req.headers.if_none_match == etag:
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
# The client has it cached, respond 304 Not Modified
|
|
||||||
return empty(304, headers=headers)
|
|
||||||
|
|
||||||
if not path.is_file():
|
|
||||||
raise NotFound("File not found")
|
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
|
||||||
)
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
|
||||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
|
||||||
if mimetypes.guess_type(path.name)[0].startswith("video/"):
|
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
|
||||||
img = Image.open(path)
|
|
||||||
w, h = img.size
|
|
||||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
|
||||||
# Fix rotation based on EXIF data
|
|
||||||
try:
|
|
||||||
rotate_values = {3: 180, 6: 270, 8: 90}
|
|
||||||
orientation = img._getexif().get(274)
|
|
||||||
if orientation in rotate_values:
|
|
||||||
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
|
||||||
img = img.rotate(rotate_values[orientation], expand=True)
|
|
||||||
except AttributeError:
|
|
||||||
...
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error rotating preview image: {e}")
|
|
||||||
# Save as webp
|
|
||||||
imgdata = io.BytesIO()
|
|
||||||
img.save(imgdata, format="webp", quality=quality, method=4)
|
|
||||||
return imgdata.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|
||||||
pdf = fitz.open(path)
|
|
||||||
page = pdf.load_page(page_number)
|
|
||||||
w, h = page.rect[2:4]
|
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
|
||||||
mat = fitz.Matrix(zoom, zoom)
|
|
||||||
pix = page.get_pixmap(matrix=mat)
|
|
||||||
return pix.pil_tobytes(format="webp", quality=quality, method=4)
|
|
||||||
|
|
||||||
|
|
||||||
def process_video(path, *, maxsize, quality):
|
|
||||||
with av.open(str(path)) as container:
|
|
||||||
stream = container.streams.video[0]
|
|
||||||
stream.codec_context.skip_frame = "NONKEY"
|
|
||||||
rot = stream.side_data and stream.side_data.get(DISPLAYMATRIX) or 0
|
|
||||||
container.seek(container.duration // 8)
|
|
||||||
img = next(container.decode(stream)).to_image()
|
|
||||||
del stream
|
|
||||||
|
|
||||||
img.thumbnail((maxsize, maxsize))
|
|
||||||
imgdata = io.BytesIO()
|
|
||||||
if rot:
|
|
||||||
img = img.rotate(rot, expand=True)
|
|
||||||
img.save(imgdata, format="webp", quality=quality, method=4)
|
|
||||||
del img
|
|
||||||
ret = imgdata.getvalue()
|
|
||||||
del imgdata
|
|
||||||
gc.collect()
|
|
||||||
return ret
|
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"""Preview generation worker subprocess and synchronous preview engine.
|
||||||
|
|
||||||
|
Two modes are supported:
|
||||||
|
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||||
|
2) Long-lived mode: read framed requests from stdin and write framed responses.
|
||||||
|
|
||||||
|
Framed request format (stdin):
|
||||||
|
(uint32 json size)(uint32 data size)(json)(binary data)
|
||||||
|
|
||||||
|
Framed response format (stdout):
|
||||||
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import gc
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import av
|
||||||
|
import msgspec
|
||||||
|
import numpy as np
|
||||||
|
import pymupdf
|
||||||
|
import pyvips
|
||||||
|
from blake3 import blake3
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
from cista.util.logformat import format_level_prefix
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _WorkerLogFormatter(logging.Formatter):
|
||||||
|
"""Emoji level prefix like the main process, tagged with the worker pid."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
prefix = format_level_prefix(record.levelno)
|
||||||
|
return f"{prefix}worker[{os.getpid()}]: {record.getMessage()}"
|
||||||
|
|
||||||
|
|
||||||
|
AVIF_FAST_EFFORT = 0
|
||||||
|
|
||||||
|
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||||
|
|
||||||
|
OFFICE_PREVIEW_SUFFIXES = {
|
||||||
|
".doc",
|
||||||
|
".dot",
|
||||||
|
".docx",
|
||||||
|
".docm",
|
||||||
|
".dotx",
|
||||||
|
".dotm",
|
||||||
|
".rtf",
|
||||||
|
".odt",
|
||||||
|
".ott",
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".mhtml",
|
||||||
|
".mht",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".xml",
|
||||||
|
".wps",
|
||||||
|
".wri",
|
||||||
|
# Spreadsheets
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".xlsm",
|
||||||
|
".xlsb",
|
||||||
|
".xltx",
|
||||||
|
".xltm",
|
||||||
|
".ods",
|
||||||
|
".ots",
|
||||||
|
".csv",
|
||||||
|
# Presentations
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".pptm",
|
||||||
|
".pps",
|
||||||
|
".ppsx",
|
||||||
|
".pot",
|
||||||
|
".potx",
|
||||||
|
".odp",
|
||||||
|
".otp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
||||||
|
path: str
|
||||||
|
quality: int
|
||||||
|
maxsize: int
|
||||||
|
maxzoom: float
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||||
|
ok: bool
|
||||||
|
mime: str | None = None
|
||||||
|
backend: str | None = None
|
||||||
|
timings: list[float] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_enc = msgspec.json.Encoder()
|
||||||
|
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exactly(f, n: int) -> bytes:
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = f.read(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
||||||
|
try:
|
||||||
|
header = _read_exactly(sys.stdin.buffer, 8)
|
||||||
|
except EOFError:
|
||||||
|
return None
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
meta_raw = _read_exactly(sys.stdin.buffer, json_size)
|
||||||
|
data = b""
|
||||||
|
if data_size:
|
||||||
|
data = _read_exactly(sys.stdin.buffer, data_size)
|
||||||
|
req = _dec_req.decode(meta_raw)
|
||||||
|
return req, data
|
||||||
|
|
||||||
|
|
||||||
|
# Raw stdout buffer reserved for the binary protocol once main() redirects
|
||||||
|
# Python-level stdout to stderr. None means "use sys.stdout.buffer as-is"
|
||||||
|
# (CLI single-shot mode, where real stdout is wanted).
|
||||||
|
_protocol_out = None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
|
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
|
||||||
|
meta_bytes = _enc.encode(resp)
|
||||||
|
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||||
|
checksum = blake3(packet).digest()
|
||||||
|
out.write(checksum)
|
||||||
|
out.write(packet)
|
||||||
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||||
|
backend = "unknown"
|
||||||
|
try:
|
||||||
|
if data:
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image_buffer(
|
||||||
|
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
||||||
|
)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
backend = "pdf"
|
||||||
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
backend = "video"
|
||||||
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
except ValueError as e:
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Preview dispatch failed for %s", path)
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(path, *, maxsize, quality):
|
||||||
|
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
|
||||||
|
"""Probe image dimensions.
|
||||||
|
|
||||||
|
pyvips can read the header of most formats (including HEIC) without
|
||||||
|
fully decoding the image.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_file(str(path))
|
||||||
|
img = img.autorot()
|
||||||
|
except pyvips.error.Error:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return img.width, img.height
|
||||||
|
|
||||||
|
|
||||||
|
def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||||
|
"""Convert any image to AVIF using ffmpeg CLI.
|
||||||
|
|
||||||
|
ffmpeg handles HEIC tile assembly, EXIF rotation, HDR metadata and
|
||||||
|
ICC profile embedding automatically.
|
||||||
|
"""
|
||||||
|
dims = _get_image_dimensions(path)
|
||||||
|
crf = int(63 * (1 - quality / 100) ** 2)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".avif", delete=False) as tmp_f:
|
||||||
|
tmp_path = tmp_f.name
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
# Keep error messages, drop the banner/config/stream-mapping spam.
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostats",
|
||||||
|
# No interactive keyboard prompts ("Press [q] to stop ...").
|
||||||
|
"-nostdin",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(path),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-c:v",
|
||||||
|
"av1",
|
||||||
|
"-crf",
|
||||||
|
str(crf),
|
||||||
|
"-cpu-used",
|
||||||
|
"8",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
if dims is not None:
|
||||||
|
w, h = dims
|
||||||
|
if max(w, h) > maxsize:
|
||||||
|
scale = min(maxsize / w, maxsize / h)
|
||||||
|
new_w = int(w * scale)
|
||||||
|
new_h = int(h * scale)
|
||||||
|
# insert -s <wxh> right after the input file
|
||||||
|
input_index = cmd.index(str(path)) + 1
|
||||||
|
cmd.insert(input_index, "-s")
|
||||||
|
cmd.insert(input_index + 1, f"{new_w}x{new_h}")
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
# stdin=DEVNULL is critical: ffmpeg must not inherit the worker's
|
||||||
|
# stdin, which carries the framed request protocol. An inherited
|
||||||
|
# stdin lets ffmpeg eat protocol bytes and, if the worker is
|
||||||
|
# killed mid-conversion, keeps the orphaned ffmpeg holding the
|
||||||
|
# pipe open so the parent's proc.wait() hangs forever.
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
shell=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
shell_cmd = shlex.join(cmd)
|
||||||
|
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||||
|
if stderr:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}):\n{shell_cmd}\n{stderr}"
|
||||||
|
) from e
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}):\n{shell_cmd}"
|
||||||
|
) from e
|
||||||
|
with Path(tmp_path).open("rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_pyvips(path, *, maxsize, quality):
|
||||||
|
t_start = perf_counter()
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
|
||||||
|
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||||
|
# skip pyvips entirely.
|
||||||
|
if suffix in (".heic", ".heif"):
|
||||||
|
heic_dims = _get_image_dimensions(path)
|
||||||
|
width, height = heic_dims or (None, None)
|
||||||
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
|
t_end = perf_counter()
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="ffmpeg",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other image formats: pyvips only. ffmpeg is not a useful fallback
|
||||||
|
# here — when pyvips cannot decode a file, ffmpeg's image decoders
|
||||||
|
# cannot either, and their failure output is far noisier.
|
||||||
|
load_opts = {"access": "sequential"}
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||||
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
keep="none",
|
||||||
|
)
|
||||||
|
except pyvips.error.Error as e:
|
||||||
|
raise ValueError(f"cannot decode image: {e}") from e
|
||||||
|
backend = "pyvips"
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend=backend,
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||||
|
_ = maxzoom
|
||||||
|
t_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_buffer(data, "")
|
||||||
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
keep="none",
|
||||||
|
)
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="pyvips",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
with pymupdf.open(path) as pdf:
|
||||||
|
page = pdf.load_page(page_number)
|
||||||
|
w, h = page.rect[2:4]
|
||||||
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
|
mat = pymupdf.Matrix(zoom, zoom)
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_memory(
|
||||||
|
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||||
|
)
|
||||||
|
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
|
||||||
|
backend = "pdf+pyvips"
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend=backend,
|
||||||
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
|
width=round(w),
|
||||||
|
height=round(h),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(path, *, maxsize, quality):
|
||||||
|
frame = None
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
istream = ostream = icc = occ = frame = None
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
||||||
|
t_load_end = t_load_start
|
||||||
|
t_save_start = t_load_start
|
||||||
|
t_save_end = t_load_start
|
||||||
|
with (
|
||||||
|
av.open(
|
||||||
|
str(path),
|
||||||
|
options={
|
||||||
|
"analyzeduration": "1000000", # 1 second (in microseconds)
|
||||||
|
"fflags": "fastseek",
|
||||||
|
},
|
||||||
|
) as icontainer,
|
||||||
|
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||||
|
):
|
||||||
|
istream = icontainer.streams.video[0]
|
||||||
|
istream.codec_context.skip_frame = "NONKEY"
|
||||||
|
icontainer.seek((icontainer.duration or 0) // 8)
|
||||||
|
for frame in icontainer.decode(istream):
|
||||||
|
if frame.dts is not None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
|
# Resize frame to thumbnail size
|
||||||
|
# Capture display dimensions before resize (accounting for rotation)
|
||||||
|
disp_w = frame.width
|
||||||
|
disp_h = frame.height
|
||||||
|
if frame.rotation in (90, 270):
|
||||||
|
disp_w, disp_h = disp_h, disp_w
|
||||||
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
|
new_width = int(frame.width * scale_factor)
|
||||||
|
new_height = int(frame.height * scale_factor)
|
||||||
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
|
# Apply EXIF rotation if present
|
||||||
|
if frame.rotation:
|
||||||
|
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||||
|
# np.rot90 rotates counter-clockwise, so we negate k
|
||||||
|
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||||
|
if k == 2:
|
||||||
|
# 180° rotation can be done in YUV420p, preserving HDR
|
||||||
|
try:
|
||||||
|
fplanes = frame.to_ndarray()
|
||||||
|
# Split into Y, U, V planes of proper dimensions
|
||||||
|
planes = [
|
||||||
|
fplanes[: frame.height],
|
||||||
|
fplanes[
|
||||||
|
frame.height : frame.height + frame.height // 4
|
||||||
|
].reshape(frame.height // 2, frame.width // 2),
|
||||||
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
|
frame.height // 2, frame.width // 2
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Rotate each plane by 180°
|
||||||
|
planes = [np.rot90(p, 2) for p in planes]
|
||||||
|
# Restore PyAV format
|
||||||
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
|
-1, planes[0].shape[1]
|
||||||
|
)
|
||||||
|
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||||
|
del planes, fplanes
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error rotating video frame by 180°")
|
||||||
|
elif k in (1, 3):
|
||||||
|
# 90° or 270° rotation requires RGB conversion (loses HDR)
|
||||||
|
try:
|
||||||
|
rgb = frame.to_ndarray(format="rgb24")
|
||||||
|
rgb = np.rot90(rgb, k)
|
||||||
|
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
|
||||||
|
frame = frame.reformat(
|
||||||
|
format="yuv420p"
|
||||||
|
) # Convert back for encoding
|
||||||
|
del rgb
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Error rotating video frame by %s°", frame.rotation
|
||||||
|
)
|
||||||
|
|
||||||
|
# libsvtav1 rejects full-range JPEG-style YUV pixel formats such as
|
||||||
|
# yuvj420p, so normalize them before opening the encoder.
|
||||||
|
if frame.format.name.startswith("yuvj"):
|
||||||
|
frame = frame.reformat(format="yuv420p")
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
||||||
|
ostream = ocontainer.add_stream(
|
||||||
|
"av1",
|
||||||
|
options={
|
||||||
|
"crf": crf,
|
||||||
|
"usage": "realtime",
|
||||||
|
"cpu-used": "8",
|
||||||
|
"threads": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(ostream, av.VideoStream):
|
||||||
|
raise TypeError("failed to initialize AV1 video stream")
|
||||||
|
ostream.width = frame.width
|
||||||
|
ostream.height = frame.height
|
||||||
|
ostream.pix_fmt = frame.format.name
|
||||||
|
icc = istream.codec_context
|
||||||
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
# Copy HDR metadata from input video stream
|
||||||
|
occ.color_primaries = icc.color_primaries
|
||||||
|
occ.color_trc = icc.color_trc
|
||||||
|
occ.colorspace = icc.colorspace
|
||||||
|
occ.color_range = icc.color_range
|
||||||
|
|
||||||
|
ocontainer.mux(ostream.encode(frame))
|
||||||
|
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
# Capture result before cleanup
|
||||||
|
ret = imgdata.getvalue()
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="video",
|
||||||
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
|
width=disp_w,
|
||||||
|
height=disp_h,
|
||||||
|
)
|
||||||
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
|
gc.collect()
|
||||||
|
return ret, resp
|
||||||
|
|
||||||
|
|
||||||
|
def _run_once() -> None:
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
quality = int(sys.argv[2])
|
||||||
|
maxsize = int(sys.argv[3])
|
||||||
|
maxzoom = float(sys.argv[4])
|
||||||
|
result, _ = dispatch(path, quality, maxsize, maxzoom)
|
||||||
|
if result:
|
||||||
|
sys.stdout.buffer.write(result)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop() -> None:
|
||||||
|
while True:
|
||||||
|
result = _read_request()
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
req, data = result
|
||||||
|
stderr_capture = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(stderr_capture)
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stderr(stderr_capture):
|
||||||
|
result, resp = dispatch(
|
||||||
|
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
if captured:
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=False,
|
||||||
|
backend=resp.backend,
|
||||||
|
error=resp.error,
|
||||||
|
stderr=captured,
|
||||||
|
)
|
||||||
|
_write_response(resp, result or b"")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Preview worker error for %s", req.path)
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
_write_response(
|
||||||
|
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
root_logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Configure all log output to stderr before any imports that may emit
|
||||||
|
# logs. stderr is inherited by the parent, so this lands in the server
|
||||||
|
# log, formatted like the main process and tagged with the worker pid.
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(_WorkerLogFormatter())
|
||||||
|
logging.basicConfig(level=logging.INFO, handlers=[handler])
|
||||||
|
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
|
||||||
|
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
||||||
|
try:
|
||||||
|
config.load_config()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("preview-worker failed to load config at startup")
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
_run_once()
|
||||||
|
return
|
||||||
|
# The command channel is a binary protocol on fd 1. Anything printed to
|
||||||
|
# stdout by Python code (e.g. a library emitting a warning via print())
|
||||||
|
# would corrupt the protocol, so redirect Python-level stdout to stderr
|
||||||
|
# (the server log) and keep the raw buffer solely for protocol traffic.
|
||||||
|
global _protocol_out
|
||||||
|
_protocol_out = sys.stdout.buffer
|
||||||
|
sys.stdout = sys.stderr
|
||||||
|
# Eagerly import heavy modules before signalling readiness so the parent
|
||||||
|
# does not hand us a request while we are still initialising.
|
||||||
|
_protocol_out.write(b"\x01")
|
||||||
|
_protocol_out.flush()
|
||||||
|
_run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,108 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import BadRequest
|
|
||||||
|
|
||||||
from cista import config
|
|
||||||
from cista.util import filename
|
|
||||||
|
|
||||||
## Control commands
|
|
||||||
|
|
||||||
|
|
||||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
|
||||||
def __call__(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class MkDir(ControlBase):
|
|
||||||
path: str
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
path = config.config.path / filename.sanitize(self.path)
|
|
||||||
path.mkdir(parents=True, exist_ok=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Rename(ControlBase):
|
|
||||||
path: str
|
|
||||||
to: str
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
to = filename.sanitize(self.to)
|
|
||||||
if "/" in to:
|
|
||||||
raise BadRequest("Rename 'to' name should only contain filename, not path")
|
|
||||||
path = config.config.path / filename.sanitize(self.path)
|
|
||||||
path.rename(path.with_name(to))
|
|
||||||
|
|
||||||
|
|
||||||
class Rm(ControlBase):
|
|
||||||
sel: list[str]
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
root = config.config.path
|
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
|
||||||
for p in sel:
|
|
||||||
if p.is_dir():
|
|
||||||
shutil.rmtree(p)
|
|
||||||
else:
|
|
||||||
p.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
class Mv(ControlBase):
|
|
||||||
sel: list[str]
|
|
||||||
dst: str
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
root = config.config.path
|
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
|
||||||
dst = root / filename.sanitize(self.dst)
|
|
||||||
if not dst.is_dir():
|
|
||||||
raise BadRequest("The destination must be a directory")
|
|
||||||
for p in sel:
|
|
||||||
shutil.move(p, dst)
|
|
||||||
|
|
||||||
|
|
||||||
class Cp(ControlBase):
|
|
||||||
sel: list[str]
|
|
||||||
dst: str
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
root = config.config.path
|
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
|
||||||
dst = root / filename.sanitize(self.dst)
|
|
||||||
if not dst.is_dir():
|
|
||||||
raise BadRequest("The destination must be a directory")
|
|
||||||
for p in sel:
|
|
||||||
if p.is_dir():
|
|
||||||
# Note: copies as dst rather than in dst unless name is appended.
|
|
||||||
shutil.copytree(
|
|
||||||
p,
|
|
||||||
dst / p.name,
|
|
||||||
dirs_exist_ok=True,
|
|
||||||
ignore_dangling_symlinks=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
shutil.copy2(p, dst)
|
|
||||||
|
|
||||||
|
|
||||||
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
|
||||||
|
|
||||||
|
|
||||||
## File uploads and downloads
|
|
||||||
|
|
||||||
|
|
||||||
class FileRange(msgspec.Struct):
|
|
||||||
name: str
|
|
||||||
size: int
|
|
||||||
start: int
|
|
||||||
end: int
|
|
||||||
|
|
||||||
|
|
||||||
class StatusMsg(msgspec.Struct):
|
|
||||||
status: str
|
|
||||||
req: FileRange
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorMsg(msgspec.Struct):
|
class ErrorMsg(msgspec.Struct):
|
||||||
@@ -112,13 +12,15 @@ class ErrorMsg(msgspec.Struct):
|
|||||||
## Directory listings
|
## Directory listings
|
||||||
|
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
|
||||||
level: int
|
level: int
|
||||||
name: str
|
name: str
|
||||||
key: str
|
key: str
|
||||||
mtime: int
|
mtime: int
|
||||||
size: int
|
size: int
|
||||||
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
ar: float | None = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key or "FileEntry()"
|
return self.key or "FileEntry()"
|
||||||
@@ -127,8 +29,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
return f"{self.name} ({self.size}, {self.mtime})"
|
return f"{self.name} ({self.size}, {self.mtime})"
|
||||||
|
|
||||||
|
|
||||||
class Update(msgspec.Struct, array_like=True):
|
class Update(msgspec.Struct, array_like=True): ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class UpdKeep(Update, tag="k"):
|
class UpdKeep(Update, tag="k"):
|
||||||
@@ -150,5 +51,6 @@ class UpdateMessage(msgspec.Struct):
|
|||||||
class Space(msgspec.Struct):
|
class Space(msgspec.Struct):
|
||||||
disk: int
|
disk: int
|
||||||
free: int
|
free: int
|
||||||
usage: int
|
used: int
|
||||||
storage: int
|
storage: int
|
||||||
|
allocated: int
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Custom access logging middleware for Sanic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from ipaddress import IPv6Address
|
||||||
|
|
||||||
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||||
|
|
||||||
|
from cista.util.logformat import EmojiFormatter as _EmojiFormatter
|
||||||
|
from cista.util.logformat import display_width as _display_width
|
||||||
|
|
||||||
|
logger = logging.getLogger("cista.access")
|
||||||
|
|
||||||
|
|
||||||
|
class ReentrantSafeStreamHandler(logging.StreamHandler):
|
||||||
|
"""Stream handler that degrades gracefully on signal-time reentrant writes.
|
||||||
|
|
||||||
|
Python's buffered text streams are not reentrant. If a signal handler logs
|
||||||
|
while another log write is in progress, StreamHandler.emit can raise:
|
||||||
|
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
|
||||||
|
|
||||||
|
Instead of letting logging emit a long "--- Logging error ---" traceback,
|
||||||
|
we fall back to a best-effort os.write to the same file descriptor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
msg = ""
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
stream = self.stream
|
||||||
|
stream.write(msg + self.terminator)
|
||||||
|
self.flush()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "reentrant call inside" not in str(exc):
|
||||||
|
self.handleError(record)
|
||||||
|
return
|
||||||
|
stream = self.stream
|
||||||
|
fd = stream.fileno()
|
||||||
|
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||||
|
data = (msg + self.terminator).encode(encoding, errors="replace")
|
||||||
|
os.write(fd, data)
|
||||||
|
except RecursionError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||||
|
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||||
|
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||||
|
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||||
|
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||||
|
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||||
|
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||||
|
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||||
|
_PATH = "\033[38;5;250m" # path (light grey)
|
||||||
|
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||||
|
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||||
|
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||||
|
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
||||||
|
|
||||||
|
|
||||||
|
def format_ipv6_network(ip: str) -> str:
|
||||||
|
"""Format IPv6 address to show only network part (first 64 bits)."""
|
||||||
|
try:
|
||||||
|
ip = ip.strip("[]")
|
||||||
|
if "%" in ip:
|
||||||
|
ip = ip.split("%")[0]
|
||||||
|
addr = IPv6Address(ip)
|
||||||
|
if addr.is_loopback:
|
||||||
|
return "::1"
|
||||||
|
if addr.is_unspecified:
|
||||||
|
return "::"
|
||||||
|
if addr.ipv4_mapped:
|
||||||
|
return str(addr.ipv4_mapped)
|
||||||
|
if addr.is_link_local:
|
||||||
|
return str(addr)
|
||||||
|
network_int = int(addr) >> 64
|
||||||
|
groups = []
|
||||||
|
for _ in range(4):
|
||||||
|
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
||||||
|
network_int >>= 16
|
||||||
|
result = ":".join(groups) + "::"
|
||||||
|
return str(IPv6Address(result + "0")).removesuffix("::")
|
||||||
|
except Exception:
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def format_client_ip(ip: str) -> str:
|
||||||
|
"""Format client IP, compressing IPv6 to network part only."""
|
||||||
|
if not ip or ip == "-":
|
||||||
|
return "-"
|
||||||
|
stripped = ip.strip("[]")
|
||||||
|
if ":" in stripped:
|
||||||
|
return format_ipv6_network(stripped)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def status_color(status: int) -> str:
|
||||||
|
if status < 200:
|
||||||
|
return _STATUS_INFO
|
||||||
|
if status < 300:
|
||||||
|
return _STATUS_OK
|
||||||
|
if status < 400:
|
||||||
|
return _STATUS_REDIRECT
|
||||||
|
if status < 500:
|
||||||
|
return _STATUS_CLIENT_ERR
|
||||||
|
return _STATUS_SERVER_ERR
|
||||||
|
|
||||||
|
|
||||||
|
def method_color(method: str) -> str:
|
||||||
|
if method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
return _METHOD_READ
|
||||||
|
return _METHOD_WRITE
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration_ms(duration_ms: float) -> str:
|
||||||
|
rounded_ms = round(duration_ms)
|
||||||
|
if rounded_ms < 2000:
|
||||||
|
return f"{rounded_ms}ms"
|
||||||
|
total_s = round(duration_ms / 1000)
|
||||||
|
if total_s < 60:
|
||||||
|
return f"{total_s}s"
|
||||||
|
if total_s <= 3600:
|
||||||
|
minutes, seconds = divmod(total_s, 60)
|
||||||
|
return f"{minutes}m{seconds}s"
|
||||||
|
hours, remainder = divmod(total_s, 3600)
|
||||||
|
minutes = round(remainder / 60)
|
||||||
|
if minutes == 60:
|
||||||
|
hours += 1
|
||||||
|
minutes = 0
|
||||||
|
return f"{hours}h{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_left(label: str) -> str:
|
||||||
|
return label[:19].ljust(19)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_method_label(label: str, *, color: str | None = None) -> str:
|
||||||
|
color_value = _METHOD_WRITE if color is None else color
|
||||||
|
padding = max(0, 7 - _display_width(label))
|
||||||
|
return f"{color_value}{label}{' ' * padding}{_RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_access_log(
|
||||||
|
client: str,
|
||||||
|
status: int,
|
||||||
|
method: str,
|
||||||
|
host: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
duration_ms: float,
|
||||||
|
extra: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
ip = _format_left(format_client_ip(client))
|
||||||
|
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
|
||||||
|
method_str = _format_method_label(method, color=method_color(method))
|
||||||
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
return (
|
||||||
|
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ws_counter = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _next_ws_id() -> int:
|
||||||
|
global _ws_counter
|
||||||
|
ws_id = _ws_counter
|
||||||
|
_ws_counter += 1
|
||||||
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
|
||||||
|
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
|
||||||
|
color = _WS_OPEN if bright else _WS_CLOSE
|
||||||
|
return f"{color}{value.rjust(3)}{_RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_ws_open(request, extra: str | None = None) -> int:
|
||||||
|
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
|
||||||
|
ws_id = _next_ws_id()
|
||||||
|
|
||||||
|
client = request.client_ip or "-"
|
||||||
|
host = request.host or "-"
|
||||||
|
path = request.path
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
ip = _format_left(format_client_ip(client))
|
||||||
|
id_str = _format_ws_id(ws_id, bright=True)
|
||||||
|
|
||||||
|
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||||
|
show_origin = origin_host and origin_host != host
|
||||||
|
|
||||||
|
method_str = _format_method_label("🔌", color=_WS_OPEN)
|
||||||
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s %s %s %s%s%s",
|
||||||
|
ip,
|
||||||
|
id_str,
|
||||||
|
method_str,
|
||||||
|
host_str,
|
||||||
|
path_str,
|
||||||
|
origin_str + extra_str,
|
||||||
|
)
|
||||||
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
|
WS_CLOSE_CODES = {
|
||||||
|
1000: "ok",
|
||||||
|
1001: "going away",
|
||||||
|
1002: "protocol error",
|
||||||
|
1003: "unsupported",
|
||||||
|
1005: "no status",
|
||||||
|
1006: "abnormal",
|
||||||
|
1007: "invalid data",
|
||||||
|
1008: "policy violation",
|
||||||
|
1009: "too large",
|
||||||
|
1010: "extension required",
|
||||||
|
1011: "server error",
|
||||||
|
1012: "restarting",
|
||||||
|
1013: "try again",
|
||||||
|
1014: "bad gateway",
|
||||||
|
1015: "tls error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_ws_close(
|
||||||
|
ws_id: int, close_code: int | None, duration: float, extra: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Log WebSocket connection close with duration and status."""
|
||||||
|
id_str = _format_ws_id(ws_id)
|
||||||
|
timing = format_duration_ms(duration * 1000)
|
||||||
|
|
||||||
|
if close_code is None:
|
||||||
|
code = "----"
|
||||||
|
status = "unknown"
|
||||||
|
else:
|
||||||
|
code = str(close_code)
|
||||||
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
|
method_str = _format_method_label("closed", color=_TIMING)
|
||||||
|
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||||
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s %s %s %s %s%s",
|
||||||
|
" " * 19,
|
||||||
|
id_str,
|
||||||
|
method_str,
|
||||||
|
status_str,
|
||||||
|
timing_str,
|
||||||
|
extra_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_access_logging() -> None:
|
||||||
|
"""Configure the cista.access logger to output to stderr."""
|
||||||
|
handler = ReentrantSafeStreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
def configure_main_logging() -> None:
|
||||||
|
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
|
||||||
|
|
||||||
|
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||||
|
call Sanic makes during serve_single() / serve().
|
||||||
|
"""
|
||||||
|
for handler_name in ("console", "error_console", "access_console"):
|
||||||
|
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
|
||||||
|
"cista.sanic_logging.ReentrantSafeStreamHandler"
|
||||||
|
)
|
||||||
|
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||||
|
"class": "cista.sanic_logging._EmojiFormatter",
|
||||||
|
}
|
||||||
|
# Also reformat any handlers already attached (covers the initial Sanic() call)
|
||||||
|
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
|
||||||
|
for handler in logging.getLogger(name).handlers:
|
||||||
|
handler.setFormatter(_EmojiFormatter())
|
||||||
@@ -2,16 +2,22 @@ import os
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
from sanic.worker.loader import AppLoader
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
|
from cista.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def load_app() -> Sanic:
|
||||||
|
"""Return the app instance for spawned Sanic worker/reloader processes."""
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def run(*, dev=False):
|
def run(*, dev=False):
|
||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
from .app import app
|
_url, opts = parse_listen(config.config.listen)
|
||||||
|
|
||||||
url, opts = parse_listen(config.config.listen)
|
|
||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
confdir = config.conffile.parent
|
confdir = config.conffile.parent
|
||||||
@@ -20,42 +26,48 @@ def run(*, dev=False):
|
|||||||
server80.app.prepare(port=80, motd=False)
|
server80.app.prepare(port=80, motd=False)
|
||||||
domain = opts["host"]
|
domain = opts["host"]
|
||||||
check_cert(confdir / domain, domain)
|
check_cert(confdir / domain, domain)
|
||||||
opts["ssl"] = str(confdir / domain) # type: ignore
|
opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
|
||||||
app.prepare(
|
app.prepare(
|
||||||
**opts,
|
**opts,
|
||||||
motd=False,
|
motd=False,
|
||||||
dev=dev,
|
dev=dev,
|
||||||
auto_reload=dev,
|
auto_reload=dev,
|
||||||
access_log=True,
|
access_log=False,
|
||||||
) # type: ignore
|
) # type: ignore[call-arg]
|
||||||
if dev:
|
if dev:
|
||||||
Sanic.serve()
|
Sanic.serve(app_loader=AppLoader(factory=load_app))
|
||||||
else:
|
else:
|
||||||
Sanic.serve_single()
|
Sanic.serve_single()
|
||||||
|
|
||||||
|
|
||||||
def check_cert(certdir, domain):
|
def check_cert(certdir, domain):
|
||||||
|
_ = domain
|
||||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||||
return
|
return
|
||||||
# TODO: Use certbot to fetch a cert
|
# Certificate provisioning is external; files must exist before startup.
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_listen(listen):
|
def parse_listen(listen):
|
||||||
if listen.startswith("/"):
|
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
|
||||||
unix = Path(listen).resolve()
|
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||||
|
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||||
|
|
||||||
|
# Use fastapi_vue's parse_endpoint for everything else
|
||||||
|
endpoints = parse_endpoint(listen, default_port=8989)
|
||||||
|
ep = endpoints[0]
|
||||||
|
|
||||||
|
if "uds" in ep:
|
||||||
|
unix = Path(ep["uds"]).resolve()
|
||||||
if not unix.parent.exists():
|
if not unix.parent.exists():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||||
)
|
)
|
||||||
return "http://localhost", {"unix": unix.as_posix()}
|
return "http://localhost", {"unix": unix.as_posix()}
|
||||||
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
|
||||||
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
host, port = ep["host"], ep["port"]
|
||||||
try:
|
if len(endpoints) > 1:
|
||||||
addr, _port = listen.split(":", 1)
|
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||||
port = int(_port)
|
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||||
except Exception:
|
|
||||||
raise ValueError(f"Invalid listen address: {listen}") from None
|
|
||||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app = Sanic("server80")
|
|||||||
# Send all HTTP users to HTTPS
|
# Send all HTTP users to HTTPS
|
||||||
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
||||||
def redirect_everything_else(request, exception):
|
def redirect_everything_else(request, exception):
|
||||||
|
_ = exception
|
||||||
server, path = request.server_name, request.path
|
server, path = request.server_name, request.path
|
||||||
if server and path.startswith("/"):
|
if server and path.startswith("/"):
|
||||||
return response.redirect(f"https://{server}{path}", status=308)
|
return response.redirect(f"https://{server}{path}", status=308)
|
||||||
@@ -15,6 +16,7 @@ def redirect_everything_else(request, exception):
|
|||||||
# ACME challenge for LetsEncrypt
|
# ACME challenge for LetsEncrypt
|
||||||
@app.get("/.well-known/acme-challenge/<challenge>")
|
@app.get("/.well-known/acme-challenge/<challenge>")
|
||||||
async def letsencrypt(request, challenge):
|
async def letsencrypt(request, challenge):
|
||||||
|
_ = request
|
||||||
try:
|
try:
|
||||||
return response.text(acme_challenges[challenge])
|
return response.text(acme_challenges[challenge])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -1,43 +1,63 @@
|
|||||||
|
import secrets
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
import jwt
|
# In-memory session store: token -> {"username": str, "exp": int}
|
||||||
|
_sessions: dict[str, dict] = {}
|
||||||
from cista.config import derived_secret
|
|
||||||
|
|
||||||
|
|
||||||
def session_secret():
|
|
||||||
return derived_secret("session")
|
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "cista"
|
||||||
|
|
||||||
max_age = 365 * 86400 # Seconds since last login
|
max_age = 365 * 86400 # Seconds since last login
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
return secrets.token_urlsafe(8)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired() -> None:
|
||||||
|
now = time()
|
||||||
|
expired = [t for t, s in _sessions.items() if s["exp"] <= now]
|
||||||
|
for t in expired:
|
||||||
|
del _sessions[t]
|
||||||
|
|
||||||
|
|
||||||
def get(request):
|
def get(request):
|
||||||
try:
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
if token is None:
|
||||||
except Exception:
|
return None
|
||||||
return False if "s" in request.cookies else None
|
s = _sessions.get(token)
|
||||||
|
if s is None:
|
||||||
|
return False # Cookie present but session not found / expired
|
||||||
|
if s["exp"] <= time():
|
||||||
|
del _sessions[token]
|
||||||
|
return False
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def create(res, username, **kwargs):
|
def create(request, res, username, **kwargs):
|
||||||
data = {
|
_purge_expired()
|
||||||
"exp": int(time()) + max_age,
|
token = _token()
|
||||||
"username": username,
|
put(token, username, **kwargs)
|
||||||
**kwargs,
|
secure = request.scheme == "https"
|
||||||
}
|
res.cookies.add_cookie(
|
||||||
s = jwt.encode(data, session_secret())
|
SESSION_COOKIE_NAME,
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=max_age,
|
||||||
|
secure=secure,
|
||||||
|
host_prefix=secure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update(res, s, **kwargs):
|
def delete(request, res):
|
||||||
s.update(kwargs)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
s = jwt.encode(s, session_secret())
|
if token is not None:
|
||||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
_sessions.pop(token, None)
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
secure = request.scheme == "https"
|
||||||
|
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
|
||||||
|
|
||||||
|
|
||||||
def delete(res):
|
def put(token: str, username: str, **kwargs) -> None:
|
||||||
res.cookies.delete_cookie("s")
|
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
|
||||||
|
|
||||||
|
|
||||||
def flash(res, message: str | None):
|
def flash(res, message: str | None):
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from stat import S_ISDIR, S_ISREG
|
||||||
|
from time import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from natsort import humansorted
|
||||||
|
from sanic.exceptions import BadRequest, NotFound
|
||||||
|
|
||||||
|
from cista import config, watching
|
||||||
|
from cista.fileio import fuid
|
||||||
|
from cista.protocol import FileEntry
|
||||||
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRootEntry(NamedTuple):
|
||||||
|
alias: str
|
||||||
|
real_rel: PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
def _token_is_share(token: config.Token) -> bool:
|
||||||
|
return token.kind == "share" and bool(token.share_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def is_share_token(token: config.Token | None) -> bool:
|
||||||
|
return bool(token and _token_is_share(token))
|
||||||
|
|
||||||
|
|
||||||
|
def build_share_roots(token: config.Token) -> list[ShareRootEntry]:
|
||||||
|
if not _token_is_share(token):
|
||||||
|
return []
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
roots: list[ShareRootEntry] = []
|
||||||
|
used_aliases: set[str] = set()
|
||||||
|
|
||||||
|
for raw_path in token.share_paths:
|
||||||
|
try:
|
||||||
|
clean = sanitize(raw_path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel = PurePosixPath(clean)
|
||||||
|
resolved = (base / rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base) or not resolved.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
display = rel.name or config.config.path.name
|
||||||
|
alias = display
|
||||||
|
suffix = 2
|
||||||
|
while alias in used_aliases:
|
||||||
|
alias = f"{display} ({suffix})"
|
||||||
|
suffix += 1
|
||||||
|
used_aliases.add(alias)
|
||||||
|
roots.append(ShareRootEntry(alias=alias, real_rel=rel))
|
||||||
|
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_virtual_path(
|
||||||
|
token: config.Token,
|
||||||
|
raw_path: str,
|
||||||
|
) -> tuple[PurePosixPath, PurePosixPath, Path, bool]:
|
||||||
|
"""Resolve a share-virtual path to real path.
|
||||||
|
|
||||||
|
Returns (virtual_rel, real_rel, real_abs, is_virtual_root).
|
||||||
|
"""
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
if raw_path.strip("/") == "":
|
||||||
|
return PurePosixPath(), PurePosixPath(), base, True
|
||||||
|
|
||||||
|
try:
|
||||||
|
clean = sanitize(raw_path)
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(f"Invalid path: {e}") from e
|
||||||
|
|
||||||
|
if not clean:
|
||||||
|
return PurePosixPath(), PurePosixPath(), base, True
|
||||||
|
|
||||||
|
virtual_rel = PurePosixPath(clean)
|
||||||
|
roots = build_share_roots(token)
|
||||||
|
if not roots:
|
||||||
|
raise NotFound("Share token has no visible files")
|
||||||
|
|
||||||
|
root_by_alias = {r.alias: r.real_rel for r in roots}
|
||||||
|
first = virtual_rel.parts[0]
|
||||||
|
real_root = root_by_alias.get(first)
|
||||||
|
if real_root is None:
|
||||||
|
raise NotFound(f"Not found: {raw_path}")
|
||||||
|
|
||||||
|
rest = virtual_rel.parts[1:]
|
||||||
|
real_rel = real_root.joinpath(*rest) if rest else real_root
|
||||||
|
resolved = (base / real_rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
return virtual_rel, real_rel, resolved, False
|
||||||
|
|
||||||
|
|
||||||
|
def real_to_virtual_aliases(token: config.Token) -> dict[PurePosixPath, str]:
|
||||||
|
return {entry.real_rel: entry.alias for entry in build_share_roots(token)}
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_virtual_entry(path: Path, name: str, level: int) -> list[FileEntry]:
|
||||||
|
st = path.lstat()
|
||||||
|
is_dir = S_ISDIR(st.st_mode)
|
||||||
|
is_file = S_ISREG(st.st_mode)
|
||||||
|
if not is_dir and not is_file:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if is_file:
|
||||||
|
try:
|
||||||
|
allocated = watching.get_allocated_size(path, st)
|
||||||
|
except Exception:
|
||||||
|
allocated = st.st_size
|
||||||
|
return [
|
||||||
|
FileEntry(
|
||||||
|
level=level,
|
||||||
|
name=name,
|
||||||
|
key=fuid(st),
|
||||||
|
mtime=int(st.st_mtime),
|
||||||
|
size=st.st_size,
|
||||||
|
allocated=allocated,
|
||||||
|
isfile=1,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
children: list[tuple[int, str, object]] = []
|
||||||
|
for child in path.iterdir():
|
||||||
|
if child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cst = child.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
c_is_file = S_ISREG(cst.st_mode)
|
||||||
|
c_is_dir = S_ISDIR(cst.st_mode)
|
||||||
|
if not c_is_file and not c_is_dir:
|
||||||
|
continue
|
||||||
|
children.append((int(c_is_file), child.name, cst))
|
||||||
|
|
||||||
|
entries: list[FileEntry] = []
|
||||||
|
agg_mtime = int(st.st_mtime)
|
||||||
|
agg_size = 0
|
||||||
|
agg_alloc = 0
|
||||||
|
|
||||||
|
for _, child_name, _ in humansorted(children):
|
||||||
|
child_path = path / child_name
|
||||||
|
child_entries = _walk_virtual_entry(child_path, child_name, level + 1)
|
||||||
|
if not child_entries:
|
||||||
|
continue
|
||||||
|
head = child_entries[0]
|
||||||
|
agg_mtime = max(agg_mtime, head.mtime)
|
||||||
|
agg_size += head.size
|
||||||
|
agg_alloc += head.allocated
|
||||||
|
entries.extend(child_entries)
|
||||||
|
|
||||||
|
head = FileEntry(
|
||||||
|
level=level,
|
||||||
|
name=name,
|
||||||
|
key=fuid(st),
|
||||||
|
mtime=agg_mtime,
|
||||||
|
size=agg_size,
|
||||||
|
allocated=agg_alloc,
|
||||||
|
isfile=0,
|
||||||
|
)
|
||||||
|
return [head, *entries]
|
||||||
|
|
||||||
|
|
||||||
|
def build_virtual_root(token: config.Token) -> list[FileEntry]:
|
||||||
|
roots = build_share_roots(token)
|
||||||
|
now = int(time())
|
||||||
|
root_key = config.derived_secret("share-root", token.key or "", token.created).hex()
|
||||||
|
|
||||||
|
entries: list[FileEntry] = []
|
||||||
|
total_size = 0
|
||||||
|
total_alloc = 0
|
||||||
|
root_mtime = 0
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
for entry in roots:
|
||||||
|
real_abs = (base / entry.real_rel).resolve()
|
||||||
|
if not real_abs.is_relative_to(base) or not real_abs.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
subtree = _walk_virtual_entry(real_abs, entry.alias, 1)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not subtree:
|
||||||
|
continue
|
||||||
|
head = subtree[0]
|
||||||
|
total_size += head.size
|
||||||
|
total_alloc += head.allocated
|
||||||
|
root_mtime = max(root_mtime, head.mtime)
|
||||||
|
entries.extend(subtree)
|
||||||
|
|
||||||
|
root = FileEntry(
|
||||||
|
level=0,
|
||||||
|
name="",
|
||||||
|
key=root_key,
|
||||||
|
mtime=root_mtime or now,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_alloc,
|
||||||
|
isfile=0,
|
||||||
|
)
|
||||||
|
return [root, *entries]
|
||||||
|
|
||||||
|
|
||||||
|
def key_paths_for_token(
|
||||||
|
token: config.Token, wanted: set[str]
|
||||||
|
) -> dict[str, PurePosixPath]:
|
||||||
|
ret: dict[str, PurePosixPath] = {}
|
||||||
|
loc = PurePosixPath()
|
||||||
|
root = build_virtual_root(token)
|
||||||
|
for f in root:
|
||||||
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
|
if f.key in wanted and f.key not in ret:
|
||||||
|
ret[f.key] = loc
|
||||||
|
if len(ret) == len(wanted):
|
||||||
|
break
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_virtual_rel_to_real(token: config.Token, rel: PurePosixPath) -> Path:
|
||||||
|
_vrel, _rrel, real_abs, is_root = resolve_virtual_path(token, rel.as_posix())
|
||||||
|
if is_root:
|
||||||
|
raise BadRequest("Virtual root is not a writable filesystem path")
|
||||||
|
return real_abs
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""SSO (paskia) authentication proxy and validation module.
|
||||||
|
|
||||||
|
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
|
||||||
|
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
|
||||||
|
- All /auth/* requests are proxied to the paskia backend
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
|
||||||
|
Must include scheme (http/https), no trailing slash
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from sanic import Blueprint, json
|
||||||
|
from sanic import raw as raw_response
|
||||||
|
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
# Auth backend URL for SSO validation (from env, no trailing slash)
|
||||||
|
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
# Validate and set PASKIA_BACKEND_URL
|
||||||
|
if _raw_url:
|
||||||
|
if not re.match(r"^https?://[^\s/]+$", _raw_url):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
|
||||||
|
"must be http(s)://host[:port] with no path or trailing slash"
|
||||||
|
)
|
||||||
|
PASKIA_BACKEND_URL = _raw_url
|
||||||
|
else:
|
||||||
|
PASKIA_BACKEND_URL = ""
|
||||||
|
|
||||||
|
|
||||||
|
def paskia_enabled() -> bool:
|
||||||
|
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
|
||||||
|
return bool(PASKIA_BACKEND_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared httpx client for SSO requests (reused for connection pooling)
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client() -> httpx.AsyncClient:
|
||||||
|
"""Get or create the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is None or _client.is_closed:
|
||||||
|
_client = httpx.AsyncClient(timeout=1.0)
|
||||||
|
if "user-agent" in _client.headers:
|
||||||
|
del _client.headers["user-agent"] # No httpx UA
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client():
|
||||||
|
"""Close the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is not None and not _client.is_closed:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_sso_request(
|
||||||
|
request, *, perm: str = "cista:login", renew: bool = True
|
||||||
|
) -> dict | None:
|
||||||
|
"""Validate an SSO request against the auth backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Sanic request object
|
||||||
|
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||||
|
renew: Whether to allow the auth backend to renew the session cookie.
|
||||||
|
Use ``False`` for WebSocket validation where Set-Cookie cannot be
|
||||||
|
forwarded to the client; this makes the request read-only and avoids
|
||||||
|
resetting the backend renewal timeout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if valid, None if validation fails with auth required response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Forbidden: If access is denied (403)
|
||||||
|
Unauthorized: If authentication is required (401)
|
||||||
|
"""
|
||||||
|
if not paskia_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if "host" in request.headers:
|
||||||
|
headers["host"] = request.headers["host"]
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "user-agent" in request.headers:
|
||||||
|
headers["user-agent"] = request.headers["user-agent"]
|
||||||
|
headers["accept"] = "application/json"
|
||||||
|
headers["x-forwarded-for"] = request.client_ip
|
||||||
|
headers["x-forwarded-host"] = request.host
|
||||||
|
headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||||
|
if not renew:
|
||||||
|
url += "&renew=0"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
request.ctx.sso_user = data
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
||||||
|
except Exception:
|
||||||
|
request.ctx.sso_user = {}
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
error_data = {"detail": response.text or "Authentication error"}
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
if "auth" in error_data and "iframe" in error_data["auth"]:
|
||||||
|
error_data["auth"]["iframe"] += "&theme=light"
|
||||||
|
raise Unauthorized(
|
||||||
|
error_data.get("detail", "Authentication required"),
|
||||||
|
"cookie",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise Forbidden(
|
||||||
|
error_data.get("detail", "Access denied"),
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
detail = error_data.get("detail", "")
|
||||||
|
logger.warning(
|
||||||
|
f"SSO validation {url} returned {response.status_code}: {detail}"
|
||||||
|
)
|
||||||
|
raise Forbidden(
|
||||||
|
detail or "Authentication error",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"SSO validation {url} network error: {e}")
|
||||||
|
raise SanicException(
|
||||||
|
"Authentication service unavailable",
|
||||||
|
status_code=502,
|
||||||
|
quiet=True,
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
async def check_permissions(user_id: str, perm: str) -> dict:
|
||||||
|
"""Check if a Paskia user has the given permission.
|
||||||
|
|
||||||
|
Calls /auth/api/check?user=<UUID>&perm=<perm> — no session or cookies needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The Paskia user UUID
|
||||||
|
perm: Permission to check (e.g. cista:login or cista:admin)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if permission is granted
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Forbidden: If permission is denied or check fails
|
||||||
|
SanicException: If the auth service is unreachable
|
||||||
|
"""
|
||||||
|
if not paskia_enabled():
|
||||||
|
raise ValueError("Paskia not enabled")
|
||||||
|
|
||||||
|
client = await get_client()
|
||||||
|
url = f"{PASKIA_BACKEND_URL}/auth/api/check"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={"user": user_id, "perm": perm},
|
||||||
|
headers={"accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
error_data = {"detail": response.text or "Permission check failed"}
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise Forbidden(
|
||||||
|
error_data.get("detail", "Access denied"),
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
raise Forbidden(
|
||||||
|
error_data.get("detail", "Permission check failed"),
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Permission check {url} network error: {e}")
|
||||||
|
raise SanicException(
|
||||||
|
"Authentication service unavailable",
|
||||||
|
status_code=502,
|
||||||
|
quiet=True,
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_request(request):
|
||||||
|
"""Proxy a request to the auth backend.
|
||||||
|
|
||||||
|
All requests under /auth/ are proxied when paskia mode is enabled.
|
||||||
|
"""
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
path = request.path
|
||||||
|
query_string = request.query_string
|
||||||
|
url = f"{PASKIA_BACKEND_URL}{path}"
|
||||||
|
if query_string:
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
skip_headers = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
"proxy-authorization",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
"forwarded",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in request.headers.items()
|
||||||
|
if key.lower() not in skip_headers
|
||||||
|
]
|
||||||
|
|
||||||
|
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
|
||||||
|
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
|
||||||
|
headers.append(("x-forwarded-host", request.host))
|
||||||
|
headers.append(("x-forwarded-proto", request.scheme))
|
||||||
|
headers.append(
|
||||||
|
(
|
||||||
|
"forwarded",
|
||||||
|
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
content=request.body or None,
|
||||||
|
) as response:
|
||||||
|
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||||
|
|
||||||
|
resp_hop_by_hop = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in response.headers.multi_items()
|
||||||
|
if key.lower() not in resp_hop_by_hop
|
||||||
|
]
|
||||||
|
|
||||||
|
return raw_response(
|
||||||
|
raw_content,
|
||||||
|
status=response.status_code,
|
||||||
|
headers=resp_headers,
|
||||||
|
content_type=response.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Auth proxy request failed: {e}")
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{"detail": "Authentication service unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_websocket(request, ws):
|
||||||
|
"""Proxy a WebSocket connection to the auth backend."""
|
||||||
|
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
|
||||||
|
if request.query_string:
|
||||||
|
url = f"{url}?{request.query_string}"
|
||||||
|
|
||||||
|
additional_headers = {}
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
additional_headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
additional_headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "host" in request.headers:
|
||||||
|
additional_headers["host"] = request.headers["host"]
|
||||||
|
if "origin" in request.headers:
|
||||||
|
additional_headers["origin"] = request.headers["origin"]
|
||||||
|
if "user-agent" in request.headers:
|
||||||
|
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||||
|
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||||
|
additional_headers["x-forwarded-host"] = request.host
|
||||||
|
additional_headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
url, additional_headers=additional_headers
|
||||||
|
) as backend_ws:
|
||||||
|
|
||||||
|
async def forward_to_backend():
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
await backend_ws.send(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("WebSocket forward_to_backend ended: %s", e)
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
try:
|
||||||
|
async for message in backend_ws:
|
||||||
|
await ws.send(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("WebSocket forward_to_client ended: %s", e)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
forward_to_backend(),
|
||||||
|
forward_to_client(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||||
|
bp = Blueprint("sso", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.websocket("/ws/<path:path>")
|
||||||
|
async def auth_websocket_proxy(request, ws, path=""):
|
||||||
|
"""Proxy WebSocket connections to the auth backend."""
|
||||||
|
_ = path
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.websocket("/ws/")
|
||||||
|
async def auth_websocket_proxy_root(request, ws):
|
||||||
|
"""Proxy root WebSocket connections to the auth backend."""
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route(
|
||||||
|
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||||
|
)
|
||||||
|
async def auth_proxy(request, path=""):
|
||||||
|
"""Proxy all auth requests to the auth backend."""
|
||||||
|
_ = path
|
||||||
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def auth_proxy_root(request):
|
||||||
|
"""Proxy root auth requests to the auth backend."""
|
||||||
|
return await proxy_auth_request(request)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import websockets.exceptions
|
||||||
from sanic import errorpages
|
from sanic import errorpages
|
||||||
from sanic.exceptions import SanicException
|
from sanic.exceptions import SanicException
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
@@ -8,6 +10,7 @@ from sanic.response import raw, redirect
|
|||||||
|
|
||||||
from cista import auth
|
from cista import auth
|
||||||
from cista.protocol import ErrorMsg
|
from cista.protocol import ErrorMsg
|
||||||
|
from cista.sanic_logging import log_ws_close, log_ws_open
|
||||||
|
|
||||||
|
|
||||||
def asend(ws, msg):
|
def asend(ws, msg):
|
||||||
@@ -22,18 +25,26 @@ def jres(data, **kwargs):
|
|||||||
|
|
||||||
async def handle_sanic_exception(request, e):
|
async def handle_sanic_exception(request, e):
|
||||||
context, code = {}, 500
|
context, code = {}, 500
|
||||||
|
headers = None
|
||||||
message = str(e)
|
message = str(e)
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
context = e.context or {}
|
context = e.context or {}
|
||||||
code = e.status_code
|
code = e.status_code
|
||||||
if not message or not request.app.debug and code == 500:
|
headers = getattr(e, "headers", None)
|
||||||
|
if not message or (not request.app.debug and code == 500):
|
||||||
message = "Internal Server Error"
|
message = "Internal Server Error"
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
|
if code == 500:
|
||||||
|
logger.exception(e)
|
||||||
# Non-browsers get JSON errors
|
# Non-browsers get JSON errors
|
||||||
if "text/html" not in request.headers.accept:
|
if "text/html" not in request.headers.accept:
|
||||||
|
# Include auth context if present (for SSO auth required responses)
|
||||||
|
# Auth must be at top level for paskia library to detect it
|
||||||
|
response_data = {"code": code, "message": message, "detail": message, **context}
|
||||||
return jres(
|
return jres(
|
||||||
ErrorMsg({"code": code, "message": message, **context}),
|
response_data,
|
||||||
status=code,
|
status=code,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
if "redirect" in context:
|
if "redirect" in context:
|
||||||
@@ -49,9 +60,20 @@ def websocket_wrapper(handler):
|
|||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
|
username = getattr(request.ctx, "username", None)
|
||||||
|
extra = username or None
|
||||||
|
start = time.perf_counter()
|
||||||
|
ws_id = log_ws_open(request, extra=extra)
|
||||||
|
close_extra = None
|
||||||
try:
|
try:
|
||||||
auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
|
except (
|
||||||
|
websockets.exceptions.ConnectionClosedOK,
|
||||||
|
websockets.exceptions.ConnectionClosedError,
|
||||||
|
):
|
||||||
|
# Normal websocket closure - already logged in access log
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
@@ -61,6 +83,21 @@ def websocket_wrapper(handler):
|
|||||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||||
if not getattr(e, "quiet", False) or code == 500:
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
logger.exception(f"{code} {e!r}")
|
logger.exception(f"{code} {e!r}")
|
||||||
|
close_extra = f"{code} {message}"
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
close_code = None
|
||||||
|
try:
|
||||||
|
p = ws.ws_proto
|
||||||
|
if p.close_rcvd is not None:
|
||||||
|
close_code = p.close_rcvd.code
|
||||||
|
elif p.close_sent is not None:
|
||||||
|
close_code = p.close_sent.code
|
||||||
|
elif getattr(p, "close_code", None) is not None:
|
||||||
|
close_code = p.close_code
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
log_ws_close(ws_id, close_code, duration, extra=close_extra)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AsyncLink:
|
|||||||
@property
|
@property
|
||||||
def to_sync(self):
|
def to_sync(self):
|
||||||
"""Yield SyncRequests from async caller when called from worker thread."""
|
"""Yield SyncRequests from async caller when called from worker thread."""
|
||||||
while (req := self._await(self._get())) is not None:
|
while (req := self.await_sync(self._get())) is not None:
|
||||||
yield SyncRequest(self, req)
|
yield SyncRequest(self, req)
|
||||||
|
|
||||||
async def _get(self):
|
async def _get(self):
|
||||||
@@ -33,14 +33,14 @@ class AsyncLink:
|
|||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _await(self, coro):
|
def await_sync(self, coro):
|
||||||
"""Run coroutine in main thread and return result; called from worker."""
|
"""Run coroutine in main thread and return result; called from worker."""
|
||||||
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
|
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Stop worker and clean up."""
|
"""Stop worker and clean up."""
|
||||||
while not self.queue.empty():
|
while not self.queue.empty():
|
||||||
command, future = self.queue.get_nowait()
|
_command, future = self.queue.get_nowait()
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(Exception("AsyncLink stopped"))
|
future.set_exception(Exception("AsyncLink stopped"))
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
@@ -87,9 +87,9 @@ class SyncRequest:
|
|||||||
def set_result(self, value):
|
def set_result(self, value):
|
||||||
"""Set result value; mark as done."""
|
"""Set result value; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, value))
|
self.alink.await_sync(set_result(self.future, value))
|
||||||
|
|
||||||
def set_exception(self, exc):
|
def set_exception(self, exc):
|
||||||
"""Set exception; mark as done."""
|
"""Set exception; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, exception=exc))
|
self.alink.await_sync(set_result(self.future, exception=exc))
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_FREE_BYTES = 128 * 1024 * 1024
|
||||||
|
_CHECK_CACHE_TTL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientStorageError(Exception):
|
||||||
|
"""Raised when there is not enough disk space for an operation."""
|
||||||
|
|
||||||
|
|
||||||
|
_cache: dict[Path, tuple[float, int]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check_free_space(path: Path) -> None:
|
||||||
|
"""Raise InsufficientStorageError if free space on the filesystem containing *path*
|
||||||
|
|
||||||
|
is below MIN_FREE_BYTES. Results are cached per directory for 1 second.
|
||||||
|
"""
|
||||||
|
check_path = path.parent if path.parent.exists() else path
|
||||||
|
check_path = check_path.resolve()
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
ts, free = _cache.get(check_path, (0, 0))
|
||||||
|
if now - ts < _CHECK_CACHE_TTL:
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
free = shutil.disk_usage(check_path).free
|
||||||
|
except OSError as e:
|
||||||
|
raise InsufficientStorageError(f"Cannot check disk usage: {e}") from e
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_cache[check_path] = (now, free)
|
||||||
|
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Shared log formatting helpers with no Sanic dependency.
|
||||||
|
|
||||||
|
Used by the main process (cista.sanic_logging) and by the preview worker
|
||||||
|
subprocess, which must not import Sanic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
LEVEL_EMOJI = {
|
||||||
|
logging.DEBUG: "🔍",
|
||||||
|
logging.INFO: "ℹ️", # noqa: RUF001
|
||||||
|
logging.WARNING: "⚠️",
|
||||||
|
logging.ERROR: "🛑",
|
||||||
|
logging.CRITICAL: "🛑",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def display_width(text: str) -> int:
|
||||||
|
return sum(
|
||||||
|
1 + (unicodedata.east_asian_width(c) in "FW")
|
||||||
|
for c in text
|
||||||
|
if unicodedata.category(c) != "Mn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_level_prefix(levelno: int) -> str:
|
||||||
|
emoji = LEVEL_EMOJI.get(levelno, "▪️")
|
||||||
|
prefix = f"{emoji} "
|
||||||
|
return prefix + (" " * max(0, 3 - display_width(prefix)))
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiFormatter(logging.Formatter):
|
||||||
|
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
return format_level_prefix(record.levelno) + record.getMessage()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
|
||||||
|
|
||||||
@@ -6,22 +7,22 @@ class LRUCache:
|
|||||||
LRUCache is a least-recently-used (LRU) cache with expiry time.
|
LRUCache is a least-recently-used (LRU) cache with expiry time.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
open (callable): Function to open a new handle.
|
opener (callable): Function to open a new handle.
|
||||||
capacity (int): Max number of items in the cache.
|
capacity (int): Max number of items in the cache.
|
||||||
maxage (float): Max age for items in cache in seconds.
|
maxage (float): Max age for items in cache in seconds.
|
||||||
cache (list): Internal list storing the cache items.
|
cache (list): Internal list storing the cache items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
def __init__(self, opener: Callable, *, capacity: int, maxage: float):
|
||||||
"""
|
"""
|
||||||
Initialize LRUCache.
|
Initialize LRUCache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
open (callable): Function to open a new handle.
|
opener (callable): Function to open a new handle.
|
||||||
capacity (int): Maximum capacity of the cache.
|
capacity (int): Maximum capacity of the cache.
|
||||||
maxage (float): Max age for items in cache in seconds.
|
maxage (float): Max age for items in cache in seconds.
|
||||||
"""
|
"""
|
||||||
self.open = open
|
self.opener = opener
|
||||||
self.capacity = capacity
|
self.capacity = capacity
|
||||||
self.maxage = maxage
|
self.maxage = maxage
|
||||||
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
|
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
|
||||||
@@ -46,11 +47,10 @@ class LRUCache:
|
|||||||
self.cache.pop(i)
|
self.cache.pop(i)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
f = self.open(key)
|
f = self.opener(key)
|
||||||
# Add/restore to end of cache
|
# Add/restore to end of cache
|
||||||
self.cache.insert(0, (key, f, monotonic()))
|
self.cache.insert(0, (key, f, monotonic()))
|
||||||
self.expire_items()
|
self.expire_items()
|
||||||
print(self.cache)
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
def expire_items(self):
|
def expire_items(self):
|
||||||
@@ -58,7 +58,9 @@ class LRUCache:
|
|||||||
Expire items that are either too old or exceed cache capacity.
|
Expire items that are either too old or exceed cache capacity.
|
||||||
"""
|
"""
|
||||||
ts = monotonic() - self.maxage
|
ts = monotonic() - self.maxage
|
||||||
while len(self.cache) > self.capacity or self.cache and self.cache[-1][2] < ts:
|
while len(self.cache) > self.capacity or (
|
||||||
|
self.cache and self.cache[-1][2] < ts
|
||||||
|
):
|
||||||
self.cache.pop()[1].close()
|
self.cache.pop()[1].close()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
from typing import Protocol
|
||||||
|
from unicodedata import normalize
|
||||||
|
|
||||||
|
import argon2
|
||||||
|
|
||||||
|
_argon = argon2.PasswordHasher()
|
||||||
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||||
|
|
||||||
|
|
||||||
|
class SupportsHash(Protocol):
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_secret(value: str) -> bytes:
|
||||||
|
return normalize("NFC", value).strip().encode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hash(user_hash: str, *, username: str, password: str) -> bool:
|
||||||
|
"""Verify password hash and return whether the stored hash should be upgraded."""
|
||||||
|
if not user_hash:
|
||||||
|
raise ValueError("Account disabled")
|
||||||
|
|
||||||
|
normalized_username = normalize_secret(username)
|
||||||
|
normalized_password = normalize_secret(password)
|
||||||
|
|
||||||
|
if (match := _droppyhash.match(user_hash)) is not None:
|
||||||
|
expected_hash, salt = match.groups()
|
||||||
|
computed_hash = hmac.digest(
|
||||||
|
normalized_password + salt.encode() + normalized_username,
|
||||||
|
b"",
|
||||||
|
"sha256",
|
||||||
|
).hex()
|
||||||
|
if not hmac.compare_digest(expected_hash, computed_hash):
|
||||||
|
raise ValueError("Invalid password")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
_argon.verify(user_hash, normalized_password)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid password") from None
|
||||||
|
return _argon.check_needs_rehash(user_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(user: SupportsHash, password: str) -> None:
|
||||||
|
user.hash = _argon.hash(normalize_secret(password))
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -16,14 +17,56 @@ from cista import config
|
|||||||
from cista.fileio import fuid
|
from cista.fileio import fuid
|
||||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inotify.adapters as inotify_adapters
|
||||||
|
except Exception:
|
||||||
|
inotify_adapters = None
|
||||||
|
|
||||||
|
# Platform-specific allocated size calculation
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
|
||||||
|
GetCompressedFileSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
|
||||||
|
GetCompressedFileSizeW.restype = wintypes.DWORD
|
||||||
|
INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
||||||
|
_ = st
|
||||||
|
high = wintypes.DWORD()
|
||||||
|
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
||||||
|
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
||||||
|
raise OSError(f"GetCompressedFileSizeW failed for {path}")
|
||||||
|
return (high.value << 32) + low
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Unix using st_blocks."""
|
||||||
|
_ = path
|
||||||
|
# st_blocks is in 512-byte units
|
||||||
|
return st.st_blocks * 512
|
||||||
|
|
||||||
|
|
||||||
pubsub = {}
|
pubsub = {}
|
||||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatUpdateLoopError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _WatcherStoppingError(Exception):
|
||||||
|
"""Internal control-flow exception for quick watcher shutdown."""
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._space = Space(0, 0, 0, 0)
|
self._space = Space(0, 0, 0, 0, 0)
|
||||||
self.root: list[FileEntry] = []
|
self.root: list[FileEntry] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -48,6 +91,7 @@ def treeiter(rootmod):
|
|||||||
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
||||||
begin = None
|
begin = None
|
||||||
ret = []
|
ret = []
|
||||||
|
|
||||||
for i, relpath, entry in treeiter(rootmod):
|
for i, relpath, entry in treeiter(rootmod):
|
||||||
if begin is None:
|
if begin is None:
|
||||||
if relpath == path:
|
if relpath == path:
|
||||||
@@ -57,6 +101,7 @@ def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
|||||||
if entry.level <= len(path.parts):
|
if entry.level <= len(path.parts):
|
||||||
break
|
break
|
||||||
ret.append(entry)
|
ret.append(entry)
|
||||||
|
|
||||||
return begin, ret
|
return begin, ret
|
||||||
|
|
||||||
|
|
||||||
@@ -77,34 +122,74 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
|
|||||||
# root
|
# root
|
||||||
level += 1
|
level += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ename = rel.parts[level - 1]
|
ename = rel.parts[level - 1]
|
||||||
name = relpath.parts[level - 1]
|
name = relpath.parts[level - 1]
|
||||||
|
|
||||||
esort = sortkey(ename)
|
esort = sortkey(ename)
|
||||||
nsort = sortkey(name)
|
nsort = sortkey(name)
|
||||||
# Non-leaf are always folders, only use relfile at leaf
|
# Non-leaf are always folders, only use relfile at leaf
|
||||||
isfile = relfile if len(relpath.parts) == level else 0
|
isfile = relfile if len(relpath.parts) == level else 0
|
||||||
|
|
||||||
# First compare by isfile, then by sorting order and if that too matches then case sensitive
|
# First compare by isfile, then by sorting order and if that too matches then case sensitive
|
||||||
cmp = (
|
cmp = (
|
||||||
entry.isfile - isfile
|
entry.isfile - isfile
|
||||||
or (esort > nsort) - (esort < nsort)
|
or (esort > nsort) - (esort < nsort)
|
||||||
or (ename > name) - (ename < name)
|
or (ename > name) - (ename < name)
|
||||||
)
|
)
|
||||||
|
|
||||||
if cmp > 0:
|
if cmp > 0:
|
||||||
return i
|
return i
|
||||||
if cmp < 0:
|
if cmp < 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
level += 1
|
level += 1
|
||||||
if level > len(relpath.parts):
|
if level > len(relpath.parts):
|
||||||
print("ERROR: insertpos", relpath, i, entry.name, entry.level, level)
|
logger.error(
|
||||||
|
f"insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return i
|
return i
|
||||||
|
|
||||||
|
|
||||||
state = State()
|
state = State()
|
||||||
rootpath: Path = None # type: ignore
|
rootpath: Path | None = None
|
||||||
quit = threading.Event()
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
# Thread-safe queue for signaling path updates from websockets
|
||||||
|
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||||
|
|
||||||
|
# Thread-safe queue for AR updates from the preview worker
|
||||||
|
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
|
||||||
|
|
||||||
|
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
|
||||||
|
_ar_map: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_ar(fuid_key: str, ar: float) -> None:
|
||||||
|
"""Called from preview handler to update the AR for a file."""
|
||||||
|
_ar_queue.put_nowait((fuid_key, ar))
|
||||||
|
|
||||||
|
|
||||||
|
def notify_change(*paths: PurePosixPath | str):
|
||||||
|
"""Signal that paths have changed. Called from control/upload websockets."""
|
||||||
|
for raw_path in paths:
|
||||||
|
path = PurePosixPath(raw_path) if isinstance(raw_path, str) else raw_path
|
||||||
|
# Convert absolute paths to relative (strip leading /)
|
||||||
|
if path.is_absolute():
|
||||||
|
path = (
|
||||||
|
PurePosixPath(*path.parts[1:])
|
||||||
|
if len(path.parts) > 1
|
||||||
|
else PurePosixPath()
|
||||||
|
)
|
||||||
|
# Skip root paths (empty, '.') to avoid full tree walks
|
||||||
|
if not path.parts or path.parts == (".",):
|
||||||
|
continue
|
||||||
|
_update_queue.put(path)
|
||||||
|
|
||||||
|
|
||||||
## Filesystem scanning
|
## Filesystem scanning
|
||||||
|
|
||||||
@@ -115,22 +200,30 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
try:
|
try:
|
||||||
st = stat or path.stat()
|
st = stat or path.stat()
|
||||||
isfile = int(not S_ISDIR(st.st_mode))
|
isfile = int(not S_ISDIR(st.st_mode))
|
||||||
|
try:
|
||||||
|
allocated = get_allocated_size(path, st) if isfile else 0
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"get_allocated_size failed for {path}")
|
||||||
|
allocated = st.st_size if isfile else 0
|
||||||
|
key = fuid(st)
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=key,
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
|
ar=_ar_map.get(key) if isfile else None,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
return [entry]
|
return [entry]
|
||||||
# Walk all entries of the directory
|
# Walk all entries of the directory
|
||||||
ret: list[FileEntry] = [...] # type: ignore
|
ret: list[FileEntry] = [...] # type: ignore[assignment]
|
||||||
li = []
|
li = []
|
||||||
for f in path.iterdir():
|
for f in path.iterdir():
|
||||||
if quit.is_set():
|
if stop_event.is_set():
|
||||||
raise SystemExit("quit")
|
raise _WatcherStoppingError
|
||||||
if f.name.startswith("."):
|
if f.name.startswith("."):
|
||||||
continue # No dotfiles
|
continue # No dotfiles
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
@@ -142,14 +235,19 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
li.append((int(isfile), f.name, s))
|
li.append((int(isfile), f.name, s))
|
||||||
# Build the tree as a list of FileEntries
|
# Build the tree as a list of FileEntries
|
||||||
for [_, name, s] in humansorted(li):
|
for [_, name, s] in humansorted(li):
|
||||||
|
if stop_event.is_set():
|
||||||
|
raise _WatcherStoppingError
|
||||||
sub = walk(rel / name, stat=s)
|
sub = walk(rel / name, stat=s)
|
||||||
|
if not sub:
|
||||||
|
continue
|
||||||
child = sub[0]
|
child = sub[0]
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=entry.level,
|
level=entry.level,
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
key=entry.key,
|
key=entry.key,
|
||||||
size=entry.size + child.size,
|
|
||||||
mtime=max(entry.mtime, child.mtime),
|
mtime=max(entry.mtime, child.mtime),
|
||||||
|
size=entry.size + child.size,
|
||||||
|
allocated=entry.allocated + child.allocated,
|
||||||
isfile=entry.isfile,
|
isfile=entry.isfile,
|
||||||
)
|
)
|
||||||
ret.extend(sub)
|
ret.extend(sub)
|
||||||
@@ -178,28 +276,31 @@ def update_root(loop):
|
|||||||
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||||
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||||
new = walk(relpath)
|
new = walk(relpath)
|
||||||
|
_ = loop
|
||||||
obegin, old = treeget(rootmod, relpath)
|
obegin, old = treeget(rootmod, relpath)
|
||||||
|
|
||||||
if old == new:
|
if old == new:
|
||||||
logger.debug(
|
|
||||||
f"Watch: Event without changes needed {relpath}"
|
|
||||||
if old
|
|
||||||
else f"Watch: Event with old and new missing: {relpath}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if obegin is not None:
|
if obegin is not None:
|
||||||
del rootmod[obegin : obegin + len(old)]
|
del rootmod[obegin : obegin + len(old)]
|
||||||
|
|
||||||
if new:
|
if new:
|
||||||
logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
|
|
||||||
i = treeinspos(rootmod, relpath, new[0].isfile)
|
i = treeinspos(rootmod, relpath, new[0].isfile)
|
||||||
rootmod[i:i] = new
|
rootmod[i:i] = new
|
||||||
else:
|
|
||||||
logger.debug(f"Watch: Removed {relpath}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_space(loop):
|
def update_space(loop):
|
||||||
"""Called periodically to update the disk usage."""
|
"""Called periodically to update the disk usage."""
|
||||||
du = shutil.disk_usage(rootpath)
|
du = shutil.disk_usage(rootpath)
|
||||||
space = Space(*du, storage=state.root[0].size)
|
root = state.root[0]
|
||||||
|
space = Space(
|
||||||
|
disk=du.total,
|
||||||
|
free=du.free,
|
||||||
|
used=du.used,
|
||||||
|
storage=root.size,
|
||||||
|
allocated=root.allocated,
|
||||||
|
)
|
||||||
# Update only on difference above 1 MB
|
# Update only on difference above 1 MB
|
||||||
tol = 10**6
|
tol = 10**6
|
||||||
old = msgspec.structs.astuple(state.space)
|
old = msgspec.structs.astuple(state.space)
|
||||||
@@ -218,17 +319,35 @@ def format_update(old, new):
|
|||||||
oremain, nremain = set(old), set(new)
|
oremain, nremain = set(old), set(new)
|
||||||
update = []
|
update = []
|
||||||
keep_count = 0
|
keep_count = 0
|
||||||
|
iteration_count = 0
|
||||||
|
# Precompute index maps to allow deterministic tie-breaking when both
|
||||||
|
# candidates exist in both sequences but are not equal (rename/move cases)
|
||||||
|
old_pos = {e: i for i, e in enumerate(old)}
|
||||||
|
new_pos = {e: i for i, e in enumerate(new)}
|
||||||
|
|
||||||
while oidx < len(old) and nidx < len(new):
|
while oidx < len(old) and nidx < len(new):
|
||||||
|
iteration_count += 1
|
||||||
|
|
||||||
|
# Emergency brake for potential infinite loops
|
||||||
|
if iteration_count > 50000:
|
||||||
|
logger.error(
|
||||||
|
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
|
||||||
|
)
|
||||||
|
raise FormatUpdateLoopError(
|
||||||
|
f"format_update infinite loop detected at iteration {iteration_count}"
|
||||||
|
)
|
||||||
|
|
||||||
modified = False
|
modified = False
|
||||||
# Matching entries are kept
|
# Matching entries are kept
|
||||||
if old[oidx] == new[nidx]:
|
if old[oidx] == new[nidx]:
|
||||||
entry = old[oidx]
|
entry = old[oidx]
|
||||||
oremain.remove(entry)
|
oremain.discard(entry)
|
||||||
nremain.remove(entry)
|
nremain.discard(entry)
|
||||||
keep_count += 1
|
keep_count += 1
|
||||||
oidx += 1
|
oidx += 1
|
||||||
nidx += 1
|
nidx += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if keep_count > 0:
|
if keep_count > 0:
|
||||||
modified = True
|
modified = True
|
||||||
update.append(UpdKeep(keep_count))
|
update.append(UpdKeep(keep_count))
|
||||||
@@ -248,7 +367,7 @@ def format_update(old, new):
|
|||||||
insert_items = []
|
insert_items = []
|
||||||
while nidx < len(new) and new[nidx] not in oremain:
|
while nidx < len(new) and new[nidx] not in oremain:
|
||||||
entry = new[nidx]
|
entry = new[nidx]
|
||||||
nremain.remove(entry)
|
nremain.discard(entry)
|
||||||
insert_items.append(entry)
|
insert_items.append(entry)
|
||||||
nidx += 1
|
nidx += 1
|
||||||
if insert_items:
|
if insert_items:
|
||||||
@@ -256,9 +375,32 @@ def format_update(old, new):
|
|||||||
update.append(UpdIns(insert_items))
|
update.append(UpdIns(insert_items))
|
||||||
|
|
||||||
if not modified:
|
if not modified:
|
||||||
raise Exception(
|
# Tie-break: both items exist in both lists but don't match here.
|
||||||
f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
|
# Decide whether to delete old[oidx] first or insert new[nidx] first
|
||||||
)
|
# based on which alignment is closer.
|
||||||
|
if oidx >= len(old) or nidx >= len(new):
|
||||||
|
break
|
||||||
|
cur_old = old[oidx]
|
||||||
|
cur_new = new[nidx]
|
||||||
|
|
||||||
|
pos_old_in_new = new_pos.get(cur_old)
|
||||||
|
pos_new_in_old = old_pos.get(cur_new)
|
||||||
|
|
||||||
|
# Default distances if not present (shouldn't happen if in remain sets)
|
||||||
|
dist_del = (pos_old_in_new - nidx) if pos_old_in_new is not None else 1
|
||||||
|
dist_ins = (pos_new_in_old - oidx) if pos_new_in_old is not None else 1
|
||||||
|
|
||||||
|
# Prefer the operation with smaller forward distance; tie => delete
|
||||||
|
if dist_del <= dist_ins:
|
||||||
|
# Delete current old item
|
||||||
|
oremain.discard(cur_old)
|
||||||
|
update.append(UpdDel(1))
|
||||||
|
oidx += 1
|
||||||
|
else:
|
||||||
|
# Insert current new item
|
||||||
|
nremain.discard(cur_new)
|
||||||
|
update.append(UpdIns([cur_new]))
|
||||||
|
nidx += 1
|
||||||
|
|
||||||
# Diff any remaining
|
# Diff any remaining
|
||||||
if keep_count > 0:
|
if keep_count > 0:
|
||||||
@@ -280,117 +422,487 @@ def format_root(root):
|
|||||||
|
|
||||||
|
|
||||||
def broadcast(msg, loop):
|
def broadcast(msg, loop):
|
||||||
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result()
|
fut = asyncio.run_coroutine_threadsafe(abroadcast(msg), loop)
|
||||||
|
return fut.result()
|
||||||
|
|
||||||
|
|
||||||
async def abroadcast(msg):
|
async def abroadcast(msg):
|
||||||
|
client_count = 0
|
||||||
try:
|
try:
|
||||||
for queue in pubsub.values():
|
for queue in pubsub.values():
|
||||||
queue.put_nowait(msg)
|
queue.put_nowait(msg)
|
||||||
|
client_count += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
# Log because asyncio would silently eat the error
|
# Log because asyncio would silently eat the error
|
||||||
logger.exception("Broadcast error")
|
logger.exception("Broadcast error")
|
||||||
|
return client_count
|
||||||
|
|
||||||
|
|
||||||
## Watcher thread
|
## Watcher thread
|
||||||
|
|
||||||
|
|
||||||
def watcher_inotify(loop):
|
class PathIndex:
|
||||||
"""Inotify watcher thread (Linux only)"""
|
"""O(1) path lookup index for the flat FileEntry tree."""
|
||||||
import inotify.adapters
|
|
||||||
|
|
||||||
modified_flags = (
|
def __init__(self, root: list[FileEntry]):
|
||||||
"IN_CREATE",
|
self.root = root
|
||||||
"IN_DELETE",
|
self._index: dict[PurePosixPath, tuple[int, int]] = {}
|
||||||
"IN_DELETE_SELF",
|
self._rebuild()
|
||||||
"IN_MODIFY",
|
|
||||||
"IN_MOVE_SELF",
|
def _rebuild(self):
|
||||||
"IN_MOVED_FROM",
|
"""Build path -> (start_idx, count) mapping in single O(n) pass."""
|
||||||
"IN_MOVED_TO",
|
index: dict[PurePosixPath, tuple[int, int]] = {}
|
||||||
)
|
path_stack: list[tuple[PurePosixPath, int]] = [] # (path, start_idx)
|
||||||
while not quit.is_set():
|
|
||||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
for i, entry in enumerate(self.root):
|
||||||
# Initialize the tree from filesystem
|
# Pop completed paths from stack
|
||||||
t0 = time.perf_counter()
|
while path_stack and entry.level <= len(path_stack[-1][0].parts):
|
||||||
update_root(loop)
|
completed_path, start_idx = path_stack.pop()
|
||||||
t1 = time.perf_counter()
|
index[completed_path] = (start_idx, i - start_idx)
|
||||||
logger.debug(f"Root update took {t1 - t0:.1f}s")
|
|
||||||
trefresh = time.monotonic() + 300.0
|
# Build current path
|
||||||
tspace = time.monotonic() + 5.0
|
if entry.level == 0:
|
||||||
# Watch for changes (frequent wakeups needed for quiting)
|
current_path = PurePosixPath()
|
||||||
while not quit.is_set():
|
else:
|
||||||
t = time.monotonic()
|
parent = path_stack[-1][0] if path_stack else PurePosixPath()
|
||||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
current_path = parent / entry.name
|
||||||
if t >= trefresh:
|
|
||||||
|
path_stack.append((current_path, i))
|
||||||
|
|
||||||
|
# Close remaining paths
|
||||||
|
for path, start_idx in path_stack:
|
||||||
|
index[path] = (start_idx, len(self.root) - start_idx)
|
||||||
|
|
||||||
|
self._index = index
|
||||||
|
|
||||||
|
def get(self, path: PurePosixPath) -> tuple[int | None, list[FileEntry]]:
|
||||||
|
"""O(1) lookup: returns (start_idx, entries) or (None, [])."""
|
||||||
|
if path not in self._index:
|
||||||
|
return None, []
|
||||||
|
start, count = self._index[path]
|
||||||
|
return start, self.root[start : start + count]
|
||||||
|
|
||||||
|
def find_insert_pos(self, path: PurePosixPath, isfile: int) -> int:
|
||||||
|
"""Find insertion position using index + binary search."""
|
||||||
|
if not path.parts:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
parent = path.parent
|
||||||
|
name = path.name
|
||||||
|
|
||||||
|
# Find parent's range
|
||||||
|
if parent == PurePosixPath():
|
||||||
|
# Insert at root level - scan root's direct children
|
||||||
|
start, count = 0, len(self.root)
|
||||||
|
target_level = 1
|
||||||
|
elif parent in self._index:
|
||||||
|
start, count = self._index[parent]
|
||||||
|
start += 1 # Skip parent entry itself
|
||||||
|
count -= 1
|
||||||
|
target_level = len(parent.parts) + 1
|
||||||
|
else:
|
||||||
|
# Parent doesn't exist, shouldn't happen
|
||||||
|
return len(self.root)
|
||||||
|
|
||||||
|
# Binary search among direct children at target_level
|
||||||
|
# Collect children indices first
|
||||||
|
children = []
|
||||||
|
i = start
|
||||||
|
end = start + count
|
||||||
|
while i < end:
|
||||||
|
entry = self.root[i]
|
||||||
|
if entry.level == target_level:
|
||||||
|
children.append(i)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not children:
|
||||||
|
return start
|
||||||
|
|
||||||
|
# Binary search for insertion point
|
||||||
|
nsort = sortkey(name)
|
||||||
|
lo, hi = 0, len(children)
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
idx = children[mid]
|
||||||
|
entry = self.root[idx]
|
||||||
|
ename = entry.name
|
||||||
|
esort = sortkey(ename)
|
||||||
|
# Compare: isfile, then sort key, then case-sensitive
|
||||||
|
cmp = (
|
||||||
|
entry.isfile - isfile
|
||||||
|
or (esort > nsort) - (esort < nsort)
|
||||||
|
or (ename > name) - (ename < name)
|
||||||
|
)
|
||||||
|
if cmp < 0:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
|
||||||
|
if lo < len(children):
|
||||||
|
return children[lo]
|
||||||
|
if children:
|
||||||
|
# Insert after last child's subtree
|
||||||
|
last_idx = children[-1]
|
||||||
|
last_entry = self.root[last_idx]
|
||||||
|
if last_entry.isfile:
|
||||||
|
return last_idx + 1
|
||||||
|
# Find end of last child's subtree
|
||||||
|
last_path = parent / last_entry.name
|
||||||
|
if last_path in self._index:
|
||||||
|
s, c = self._index[last_path]
|
||||||
|
return s + c
|
||||||
|
return last_idx + 1
|
||||||
|
return start
|
||||||
|
|
||||||
|
def apply_update(
|
||||||
|
self, path: PurePosixPath, new_entries: list[FileEntry]
|
||||||
|
) -> list[FileEntry]:
|
||||||
|
"""Apply an update and return the new root. Rebuilds index."""
|
||||||
|
start, old_entries = self.get(path)
|
||||||
|
|
||||||
|
if old_entries == new_entries:
|
||||||
|
return self.root
|
||||||
|
|
||||||
|
new_root = self.root[:]
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
del new_root[start : start + len(old_entries)]
|
||||||
|
|
||||||
|
if new_entries:
|
||||||
|
# Rebuild index on modified list to find insert pos
|
||||||
|
self.root = new_root
|
||||||
|
self._rebuild()
|
||||||
|
insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
|
||||||
|
new_root[insert_pos:insert_pos] = new_entries
|
||||||
|
|
||||||
|
self.root = new_root
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
# Recalculate sizes for ancestor folders (including root)
|
||||||
|
self._recalculate_ancestors(path)
|
||||||
|
|
||||||
|
return new_root
|
||||||
|
|
||||||
|
def _recalculate_ancestors(self, path: PurePosixPath):
|
||||||
|
"""Recalculate size/allocated for all ancestors of path, including root."""
|
||||||
|
# Build list of ancestors from deepest to root
|
||||||
|
ancestors = []
|
||||||
|
current = path.parent if path.parts else PurePosixPath()
|
||||||
|
while True:
|
||||||
|
ancestors.append(current)
|
||||||
|
if not current.parts:
|
||||||
break
|
break
|
||||||
# Disk usage update
|
current = current.parent
|
||||||
if t >= tspace:
|
|
||||||
tspace = time.monotonic() + 5.0
|
# Process from deepest ancestor to root
|
||||||
update_space(loop)
|
for ancestor_path in ancestors:
|
||||||
# Inotify events, update the tree
|
if ancestor_path not in self._index:
|
||||||
dirty = False
|
continue
|
||||||
rootmod = state.root[:]
|
start, count = self._index[ancestor_path]
|
||||||
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
|
if count == 0:
|
||||||
assert event
|
continue
|
||||||
if quit.is_set():
|
|
||||||
return
|
ancestor = self.root[start]
|
||||||
interesting = any(f in modified_flags for f in event[1])
|
if ancestor.isfile:
|
||||||
if event[2] == rootpath.as_posix() and event[3] == "zzz":
|
continue # Files don't aggregate
|
||||||
logger.debug(f"Watch: {interesting=} {event=}")
|
|
||||||
if interesting:
|
# Sum size/allocated of direct children
|
||||||
# Update modified path
|
total_size = 0
|
||||||
t0 = time.perf_counter()
|
total_allocated = 0
|
||||||
path = PurePosixPath(event[2]) / event[3]
|
i = start + 1
|
||||||
update_path(rootmod, path.relative_to(rootpath), loop)
|
while i < start + count:
|
||||||
t1 = time.perf_counter()
|
child = self.root[i]
|
||||||
logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s")
|
if child.level == ancestor.level + 1:
|
||||||
if not dirty:
|
total_size += child.size
|
||||||
t = time.monotonic()
|
total_allocated += child.allocated
|
||||||
dirty = True
|
# Skip child's subtree
|
||||||
# Wait a maximum of 0.5s to push the updates
|
child_path = ancestor_path / child.name
|
||||||
if dirty and time.monotonic() >= t + 0.5:
|
if child_path in self._index:
|
||||||
break
|
_, child_count = self._index[child_path]
|
||||||
if dirty and state.root != rootmod:
|
i += child_count
|
||||||
t0 = time.perf_counter()
|
else:
|
||||||
update = format_update(state.root, rootmod)
|
i += 1
|
||||||
t1 = time.perf_counter()
|
else:
|
||||||
with state.lock:
|
i += 1
|
||||||
broadcast(update, loop)
|
|
||||||
state.root = rootmod
|
# Update ancestor entry if changed
|
||||||
t2 = time.perf_counter()
|
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||||
logger.debug(
|
self.root[start] = FileEntry(
|
||||||
f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s"
|
level=ancestor.level,
|
||||||
|
name=ancestor.name,
|
||||||
|
key=ancestor.key,
|
||||||
|
mtime=ancestor.mtime,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_allocated,
|
||||||
|
isfile=ancestor.isfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
del i # Free the inotify object
|
|
||||||
|
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||||
|
"""Remove child paths if parent is in set."""
|
||||||
|
if not paths:
|
||||||
|
return paths
|
||||||
|
# Filter out root paths (empty or '.') which would cause full tree walks
|
||||||
|
paths = {p for p in paths if p.parts and p.parts != (".",)}
|
||||||
|
if not paths:
|
||||||
|
return set()
|
||||||
|
# Sort by depth (fewest parts first)
|
||||||
|
sorted_paths = sorted(paths, key=lambda p: len(p.parts))
|
||||||
|
result = set()
|
||||||
|
for path in sorted_paths:
|
||||||
|
# Check if any ancestor is already in result
|
||||||
|
is_child = False
|
||||||
|
for i in range(len(path.parts)):
|
||||||
|
ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
|
||||||
|
if ancestor in result:
|
||||||
|
is_child = True
|
||||||
|
break
|
||||||
|
if not is_child:
|
||||||
|
result.add(path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def watcher_poll(loop):
|
# Debounce settings
|
||||||
"""Polling version of the watcher thread."""
|
DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
|
||||||
while not quit.is_set():
|
DEBOUNCE_MAX = 0.1 # But no more than 100ms total
|
||||||
t0 = time.perf_counter()
|
|
||||||
update_root(loop)
|
|
||||||
update_space(loop)
|
|
||||||
dur = time.perf_counter() - t0
|
|
||||||
if dur > 1.0:
|
|
||||||
logger.debug(f"Reading the full file list took {dur:.1f}s")
|
|
||||||
quit.wait(0.1 + 8 * dur)
|
|
||||||
|
|
||||||
|
|
||||||
async def start(app, loop):
|
def watcher(loop):
|
||||||
|
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
|
||||||
|
use_inotify = sys.platform == "linux" and inotify_adapters is not None
|
||||||
|
inotify_tree = None
|
||||||
|
modified_flags = frozenset()
|
||||||
|
|
||||||
|
if sys.platform == "linux" and inotify_adapters is None:
|
||||||
|
logger.warning("inotify unavailable; falling back to periodic scanning")
|
||||||
|
|
||||||
|
if use_inotify:
|
||||||
|
modified_flags = frozenset(
|
||||||
|
(
|
||||||
|
"IN_CREATE",
|
||||||
|
"IN_DELETE",
|
||||||
|
"IN_DELETE_SELF",
|
||||||
|
"IN_MODIFY",
|
||||||
|
"IN_MOVE_SELF",
|
||||||
|
"IN_MOVED_FROM",
|
||||||
|
"IN_MOVED_TO",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
if use_inotify:
|
||||||
|
inotify_tree = inotify_adapters.InotifyTree(rootpath.as_posix())
|
||||||
|
|
||||||
|
# Initialize the tree from filesystem
|
||||||
|
try:
|
||||||
|
update_root(loop)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
|
path_index = PathIndex(state.root[:])
|
||||||
|
|
||||||
|
trefresh = time.monotonic() + 300.0
|
||||||
|
tspace = time.monotonic() + 5.0
|
||||||
|
|
||||||
|
# Pending changes: path -> {"ws": count, "inotify": count}
|
||||||
|
dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
|
||||||
|
first_event_time: float | None = None
|
||||||
|
last_event_time: float | None = None
|
||||||
|
|
||||||
|
def add_dirty(
|
||||||
|
path: PurePosixPath,
|
||||||
|
source: str,
|
||||||
|
dirty_paths=dirty_paths,
|
||||||
|
) -> bool:
|
||||||
|
"""Add path to dirty set. Returns True if added, False if redundant."""
|
||||||
|
nonlocal first_event_time, last_event_time
|
||||||
|
# Check if already covered by an existing dirty path
|
||||||
|
for existing in dirty_paths:
|
||||||
|
if path == existing or (
|
||||||
|
len(path.parts) > len(existing.parts)
|
||||||
|
and path.parts[: len(existing.parts)] == existing.parts
|
||||||
|
):
|
||||||
|
# Count the event even if skipped
|
||||||
|
dirty_paths[existing][source] = (
|
||||||
|
dirty_paths[existing].get(source, 0) + 1
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
# Remove any paths that would be covered by this new one
|
||||||
|
covered = {
|
||||||
|
p
|
||||||
|
for p in dirty_paths
|
||||||
|
if len(p.parts) > len(path.parts)
|
||||||
|
and p.parts[: len(path.parts)] == path.parts
|
||||||
|
}
|
||||||
|
# Aggregate counts from covered paths
|
||||||
|
counts: dict[str, int] = {source: 1}
|
||||||
|
for p in covered:
|
||||||
|
for s, c in dirty_paths[p].items():
|
||||||
|
counts[s] = counts.get(s, 0) + c
|
||||||
|
del dirty_paths[p]
|
||||||
|
dirty_paths[path] = counts
|
||||||
|
now = time.monotonic()
|
||||||
|
if first_event_time is None:
|
||||||
|
first_event_time = now
|
||||||
|
last_event_time = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Full refresh every 300s
|
||||||
|
if now >= trefresh:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Disk usage update every 5s
|
||||||
|
if now >= tspace:
|
||||||
|
tspace = now + 5.0
|
||||||
|
update_space(loop)
|
||||||
|
|
||||||
|
# Check if we should flush pending changes
|
||||||
|
should_flush = False
|
||||||
|
if dirty_paths:
|
||||||
|
time_since_last = now - last_event_time if last_event_time else 0
|
||||||
|
time_since_first = now - first_event_time if first_event_time else 0
|
||||||
|
if (
|
||||||
|
time_since_last >= DEBOUNCE_DELAY
|
||||||
|
or time_since_first >= DEBOUNCE_MAX
|
||||||
|
):
|
||||||
|
should_flush = True
|
||||||
|
|
||||||
|
if should_flush:
|
||||||
|
paths_to_process = dirty_paths.copy()
|
||||||
|
dirty_paths.clear()
|
||||||
|
first_event_time = None
|
||||||
|
last_event_time = None
|
||||||
|
|
||||||
|
# Collapse paths (remove children if parent present)
|
||||||
|
collapsed = collapse_paths(set(paths_to_process.keys()))
|
||||||
|
|
||||||
|
# Process each collapsed path
|
||||||
|
new_root = path_index.root
|
||||||
|
for path in collapsed:
|
||||||
|
try:
|
||||||
|
new_entries = walk(path)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
|
new_root = path_index.apply_update(path, new_entries)
|
||||||
|
|
||||||
|
# Broadcast if changed
|
||||||
|
if new_root != state.root:
|
||||||
|
try:
|
||||||
|
update_msg = format_update(state.root, new_root)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update_msg, loop)
|
||||||
|
state.root = new_root
|
||||||
|
except Exception:
|
||||||
|
logger.exception("format_update failed; full rescan")
|
||||||
|
try:
|
||||||
|
fresh = walk(PurePosixPath())
|
||||||
|
path_index = PathIndex(fresh)
|
||||||
|
update_msg = format_update(state.root, fresh)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update_msg, loop)
|
||||||
|
state.root = fresh
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Fallback failed; sending full root")
|
||||||
|
with state.lock:
|
||||||
|
broadcast(format_root(fresh), loop)
|
||||||
|
state.root = fresh
|
||||||
|
|
||||||
|
# Drain AR updates from preview worker (immediate, no debounce)
|
||||||
|
ar_new_root: list[FileEntry] | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
fuid_key, ar = _ar_queue.get_nowait()
|
||||||
|
_ar_map[fuid_key] = ar
|
||||||
|
# Patch the matching entry in the current root
|
||||||
|
root_to_patch = (
|
||||||
|
ar_new_root if ar_new_root is not None else path_index.root
|
||||||
|
)
|
||||||
|
for i, entry in enumerate(root_to_patch):
|
||||||
|
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
|
||||||
|
if ar_new_root is None:
|
||||||
|
ar_new_root = root_to_patch[:]
|
||||||
|
ar_new_root[i] = FileEntry(
|
||||||
|
level=entry.level,
|
||||||
|
name=entry.name,
|
||||||
|
key=entry.key,
|
||||||
|
mtime=entry.mtime,
|
||||||
|
size=entry.size,
|
||||||
|
allocated=entry.allocated,
|
||||||
|
isfile=entry.isfile,
|
||||||
|
ar=ar,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
if ar_new_root is not None:
|
||||||
|
try:
|
||||||
|
update_msg = format_update(state.root, ar_new_root)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update_msg, loop)
|
||||||
|
state.root = ar_new_root
|
||||||
|
path_index = PathIndex(ar_new_root)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AR update broadcast failed")
|
||||||
|
|
||||||
|
# Collect events from websocket signals (non-blocking)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
path = _update_queue.get_nowait()
|
||||||
|
add_dirty(path, "ws")
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Collect inotify events if available (short timeout for responsiveness)
|
||||||
|
if inotify_tree:
|
||||||
|
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
|
if not (modified_flags & set(event[1])):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract relative path
|
||||||
|
path = PurePosixPath(event[2]) / event[3]
|
||||||
|
try:
|
||||||
|
rel_path = path.relative_to(rootpath)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip dotfiles
|
||||||
|
if any(part.startswith(".") for part in rel_path.parts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
add_dirty(rel_path, "inotify")
|
||||||
|
|
||||||
|
# Don't block too long collecting events
|
||||||
|
now = time.monotonic()
|
||||||
|
if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No inotify, just sleep briefly for responsiveness
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if inotify_tree:
|
||||||
|
del inotify_tree
|
||||||
|
|
||||||
|
|
||||||
|
def start(app):
|
||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
use_inotify = sys.platform == "linux"
|
stop_event.clear()
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher_inotify if use_inotify else watcher_poll,
|
target=watcher,
|
||||||
args=[loop],
|
args=[app.loop],
|
||||||
# Descriptive name for system monitoring
|
# Descriptive name for system monitoring
|
||||||
name=f"cista-watcher {rootpath}",
|
name=f"cista-watcher {rootpath}",
|
||||||
)
|
)
|
||||||
app.ctx.watcher.start()
|
app.ctx.watcher.start()
|
||||||
|
|
||||||
|
|
||||||
async def stop(app, loop):
|
def stop(app):
|
||||||
quit.set()
|
stop_event.set()
|
||||||
app.ctx.watcher.join()
|
app.ctx.watcher.join()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
onlyoffice:
|
||||||
|
build:
|
||||||
|
context: ./docker/onlyoffice-converter-patch
|
||||||
|
args:
|
||||||
|
ONLYOFFICE_VERSION: "9.3.1"
|
||||||
|
container_name: onlyoffice
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
# Number of converter workers (default 8).
|
||||||
|
# Set to your CPU count or slightly below.
|
||||||
|
- WORKERS
|
||||||
|
# JWT secret shared with Cista.
|
||||||
|
# OnlyOffice reads it as JWT_SECRET; Cista reads it as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# We use ONLYOFFICE_JWT_SECRET as the canonical name so you only set one variable.
|
||||||
|
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
||||||
|
- JWT_ENABLED=true
|
||||||
|
- JWT_HEADER=Authorization
|
||||||
|
volumes:
|
||||||
|
# Persist fonts and generated caches across restarts
|
||||||
|
- onlyoffice-data:/var/www/onlyoffice/Data
|
||||||
|
- onlyoffice-lib:/var/lib/onlyoffice
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
onlyoffice-data:
|
||||||
|
onlyoffice-lib:
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Patched OnlyOffice Document Server with configurable converter worker count.
|
||||||
|
#
|
||||||
|
# The Community Edition hardcodes the document converter to 1 worker,
|
||||||
|
# which creates a severe bottleneck under concurrent load.
|
||||||
|
# This image patches the open-source license.js to spawn a configurable
|
||||||
|
# number of converter workers (default 8).
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# docker run -d -p 8988:80 \
|
||||||
|
# -e WORKERS=16 \
|
||||||
|
# -e JWT_SECRET=your-strong-secret \
|
||||||
|
# --name onlyoffice onlyoffice-cista
|
||||||
|
#
|
||||||
|
# JWT:
|
||||||
|
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# OnlyOffice will enable token validation automatically.
|
||||||
|
#
|
||||||
|
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
|
||||||
|
|
||||||
|
ARG ONLYOFFICE_VERSION=9.3.1
|
||||||
|
|
||||||
|
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
|
||||||
|
|
||||||
|
# Prevent interactive apt prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install Node.js, npm, and git so we can run the FileConverter from source.
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Clone the open-source server components (shallow, ~15 MB).
|
||||||
|
# The master branch is used because the Linux/web tags are not published
|
||||||
|
# in the server repo; the license.js file has been stable for years.
|
||||||
|
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
||||||
|
|
||||||
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
|
# instead of being hardcoded to 1.
|
||||||
|
RUN sed -i \
|
||||||
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Back up the compiled pkg binary and replace it with our wrapper.
|
||||||
|
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
|
||||||
|
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
|
||||||
|
|
||||||
|
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
|
||||||
|
# Default worker count (override at runtime with -e WORKERS=16).
|
||||||
|
ENV WORKERS=8
|
||||||
|
|
||||||
|
# Use our custom entrypoint to persist the env var to a file that the
|
||||||
|
# non-root converter process (user=ds) can read.
|
||||||
|
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
|
||||||
|
RUN chmod +x /app/ds/run-document-server-patched.sh
|
||||||
|
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
|
||||||
|
# Replaces the compiled pkg binary shipped with the Community Edition.
|
||||||
|
|
||||||
|
# The env var is not passed through supervisor to the 'ds' user, so we read
|
||||||
|
# it from a file written by the custom entrypoint.
|
||||||
|
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
|
||||||
|
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /opt/oo-server/FileConverter || exit 1
|
||||||
|
|
||||||
|
export NODE_ENV=production-linux
|
||||||
|
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
|
||||||
|
export NODE_DISABLE_COLORS=1
|
||||||
|
export APPLICATION_NAME=onlyoffice
|
||||||
|
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
|
||||||
|
|
||||||
|
exec node sources/convertermaster.js "$@"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Custom entrypoint that persists WORKERS to a file readable by
|
||||||
|
# the non-root user that supervisor uses to run the converter.
|
||||||
|
|
||||||
|
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
|
||||||
|
chmod 644 /tmp/oo-converter-workers.txt
|
||||||
|
|
||||||
|
exec /app/ds/run-document-server.sh "$@"
|
||||||
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 40 KiB |
@@ -22,25 +22,25 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
|
|||||||
### Run the backend
|
### Run the backend
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
hatch shell
|
uv sync --dev
|
||||||
cista --dev -l :8000
|
uv run cista --dev -l :8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### And the Vite server (in another terminal)
|
### And the Vite server (in another terminal)
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
bun install
|
||||||
npm run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
|
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
|
||||||
|
|
||||||
|
|
||||||
### Type-Check, Compile and Minify for Production
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
This is also called by `hatch build` during Python packaging:
|
This is also called by `uv build` during Python packaging:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
npm run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"files": {
|
||||||
|
"ignore": ["node_modules", "dist", "coverage", "components.d.ts"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 88
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noInferrableTypes": "off",
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"noParameterAssign": "off",
|
||||||
|
"noUselessElse": "off",
|
||||||
|
"useExponentiationOperator": "off",
|
||||||
|
"useSingleVarDeclarator": "off",
|
||||||
|
"useTemplate": "off",
|
||||||
|
"useConst": "off",
|
||||||
|
"useImportType": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noAssignInExpressions": "off",
|
||||||
|
"noDoubleEquals": "off",
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
"noImplicitAnyLet": "off",
|
||||||
|
"noMisleadingCharacterClass": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noBannedTypes": "off",
|
||||||
|
"useOptionalChain": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noSwitchDeclarations": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useGenericFontNames": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": ["**/*.d.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noBannedTypes": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "asNeeded",
|
||||||
|
"trailingCommas": "none",
|
||||||
|
"arrowParentheses": "asNeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang=en>
|
<html lang=en>
|
||||||
<meta charset=UTF-8>
|
<meta charset=UTF-8>
|
||||||
<title>Cista Storage</title>
|
<title>Cista Storage</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
|
||||||
<link rel="icon" href="/src/assets/logo.svg">
|
<link rel="icon" href="/src/assets/logo.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
@@ -6,56 +6,43 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:unit": "vitest",
|
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
"lint": "biome lint .",
|
||||||
"format": "prettier --write src/"
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format --check .",
|
||||||
|
"check": "biome check ."
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
"@codemirror/language-data": "^6.5.2",
|
||||||
"@vueuse/core": "^10.4.1",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"esbuild": "^0.19.5",
|
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||||
"lodash": "^4.17.21",
|
"@vueuse/core": "^14.1.0",
|
||||||
"lodash-es": "^4.17.21",
|
"codemirror": "^6.0.2",
|
||||||
"pinia": "^2.1.6",
|
"esbuild": "^0.27.2",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"lodash": "^4.17.23",
|
||||||
"unplugin-vue-components": "^0.25.2",
|
"lodash-es": "^4.17.23",
|
||||||
"vite-svg-loader": "^4.0.0",
|
"paskia": "^0.1.2",
|
||||||
"vue": "^3.3.4",
|
"pinia": "^3.0.4",
|
||||||
"vue-router": "^4.2.4"
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
|
"unplugin-vue-components": "^31.0.0",
|
||||||
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vue": "^3.5.27",
|
||||||
|
"vue-router": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.3.3",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@tsconfig/node18": "^18.2.2",
|
"@tsconfig/node18": "^18.2.6",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/lodash-es": "^4.17.10",
|
"@types/node": "^25.1.0",
|
||||||
"@types/node": "^18.17.17",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vitejs/plugin-vue": "^4.3.4",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"npm-run-all2": "^8.0.4",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"typescript": "~5.9.3",
|
||||||
"@vue/test-utils": "^2.4.1",
|
"vite": "^7.3.1",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"vue-tsc": "^3.2.4"
|
||||||
"babel-eslint": "^10.1.0",
|
|
||||||
"eslint": "^8.52.0",
|
|
||||||
"eslint-plugin-vue": "^9.18.1",
|
|
||||||
"jsdom": "^22.1.0",
|
|
||||||
"npm-run-all2": "^6.0.6",
|
|
||||||
"prettier": "^3.0.3",
|
|
||||||
"typescript": "~5.2.0",
|
|
||||||
"vite": "^4.4.9",
|
|
||||||
"vitest": "^0.34.4",
|
|
||||||
"vue-tsc": "^1.8.11"
|
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"printWidth": 88
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
|
||||||
|
{{ store.toast }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
|
<UserManagementModal />
|
||||||
|
<UserTokensModal />
|
||||||
|
<AboutModal />
|
||||||
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain
|
||||||
<HeaderSelected :path="path.pathList" />
|
ref="headerMain"
|
||||||
</HeaderMain>
|
:path="path.pathList"
|
||||||
<BreadCrumb :path="path.pathList" primary />
|
:query="path.query"
|
||||||
|
:editor-mode="path.isEditorPath"
|
||||||
|
/>
|
||||||
|
<BreadCrumb
|
||||||
|
:path="path.breadcrumbPathList"
|
||||||
|
:links="path.breadcrumbLinks"
|
||||||
|
primary
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="transition-wrapper">
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<Transition
|
||||||
|
:name="routeTransitionName"
|
||||||
|
@after-enter="store.transitionDirection = 'none'"
|
||||||
|
>
|
||||||
|
<div :key="routeViewKey" class="explorer-content">
|
||||||
|
<KeepAlive>
|
||||||
|
<component
|
||||||
|
:is="routeViewComponent"
|
||||||
|
:key="routeViewKey"
|
||||||
|
v-bind="routeViewProps"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
|
<SelectionToolbar :path="path.pathList" />
|
||||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
|
||||||
import type { ComputedRef } from 'vue'
|
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import type { ComputedRef } from 'vue'
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import { computed } from 'vue'
|
||||||
|
import AboutModal from './components/AboutModal.vue'
|
||||||
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
import ExplorerView from './views/ExplorerView.vue'
|
||||||
|
import TextEditorView from './views/TextEditorView.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
canonicalPath: string
|
||||||
|
isEditorPath: boolean
|
||||||
pathList: string[]
|
pathList: string[]
|
||||||
|
breadcrumbPathList: string[]
|
||||||
|
breadcrumbLinks?: string[]
|
||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const getDocByPath = (fullPath: string) =>
|
||||||
|
getDocuments().find(
|
||||||
|
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
|
||||||
|
)
|
||||||
|
|
||||||
const path: ComputedRef<Path> = computed(() => {
|
const path: ComputedRef<Path> = computed(() => {
|
||||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||||
const pathList = p[0].split('/').filter(value => value !== '')
|
const rawPath = p[0] ?? ''
|
||||||
|
const routePathList = rawPath.split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
|
const fullPath = routePathList.join('/')
|
||||||
|
// Access docVersion to make route mode reactive to tree updates
|
||||||
|
void store.docVersion
|
||||||
|
const doc = fullPath ? getDocByPath(fullPath) : null
|
||||||
|
const isEditorPath = !!(doc && !doc.dir && doc.text)
|
||||||
|
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
|
||||||
|
const canonicalPath = query
|
||||||
|
? `${rawPath}//${query}` // keep search URL shape untouched
|
||||||
|
: canonicalBase
|
||||||
|
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
|
||||||
|
const breadcrumbPathList = routePathList
|
||||||
|
const breadcrumbLinks = isEditorPath
|
||||||
|
? [
|
||||||
|
'/',
|
||||||
|
...routePathList
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
|
||||||
|
`/${fullPath}`
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
path: p[0],
|
path: rawPath,
|
||||||
|
canonicalPath,
|
||||||
|
isEditorPath,
|
||||||
pathList,
|
pathList,
|
||||||
|
breadcrumbPathList,
|
||||||
|
breadcrumbLinks,
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
const routeTransitionName = computed(() => {
|
||||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
|
return ''
|
||||||
})
|
})
|
||||||
|
const routeViewComponent = computed(() =>
|
||||||
|
path.value.isEditorPath ? TextEditorView : ExplorerView
|
||||||
|
)
|
||||||
|
const routeViewKey = computed(() => {
|
||||||
|
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
|
||||||
|
})
|
||||||
|
const routeViewProps = computed(() =>
|
||||||
|
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => path.value.canonicalPath,
|
||||||
|
canonical => {
|
||||||
|
const current = decodeURIComponent(Router.currentRoute.value.path)
|
||||||
|
if (canonical && current !== canonical) {
|
||||||
|
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => path.value.path,
|
||||||
|
() => {
|
||||||
|
document.title =
|
||||||
|
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||||
|
store.server.name ||
|
||||||
|
'Cista Storage'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
onMounted(loadSession)
|
onMounted(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
onUnmounted(watchDisconnect)
|
onUnmounted(watchDisconnect)
|
||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|
||||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
if (store.dialog) {
|
if (store.dialog) {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -65,14 +169,25 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const fileExplorer = store.fileExplorer as any
|
const fileExplorer = store.fileExplorer as any
|
||||||
if (!fileExplorer) return
|
if (!fileExplorer) return
|
||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const target = event.target as HTMLElement
|
||||||
|
const input =
|
||||||
|
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
|
|
||||||
|
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||||
|
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (event.repeat) {
|
if (event.repeat) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowUp' ||
|
event.key === 'ArrowUp' ||
|
||||||
event.key === 'ArrowDown' ||
|
event.key === 'ArrowDown' ||
|
||||||
event.key === 'ArrowLeft' ||
|
event.key === 'ArrowLeft' ||
|
||||||
event.key === 'ArrowRight' ||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
(c && event.code === 'Space')
|
(c && event.code === 'Space')
|
||||||
) {
|
) {
|
||||||
if (!input) event.preventDefault()
|
if (!input) event.preventDefault()
|
||||||
@@ -82,22 +197,64 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
let paging = ''
|
||||||
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
|
const searchInput = inHeader && input
|
||||||
|
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||||
|
if (event.key.startsWith('Arrow')) {
|
||||||
|
const dir = event.key.slice(5).toLowerCase()
|
||||||
|
// In search with text: left/right move cursor, up/down navigate
|
||||||
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
|
return // Let browser handle cursor movement
|
||||||
|
}
|
||||||
|
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||||
|
if (input && !searchInput) return
|
||||||
|
arrow = dir
|
||||||
|
} else if (
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
|
event.key === 'Home' ||
|
||||||
|
event.key === 'End'
|
||||||
|
) {
|
||||||
|
if (input) return
|
||||||
|
paging = event.key
|
||||||
|
}
|
||||||
|
if (arrow) {
|
||||||
|
// Arrow key handling - fall through to bottom
|
||||||
|
} else if (paging) {
|
||||||
|
// Paging/navigation key handling - fall through to bottom
|
||||||
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (
|
||||||
|
!path.value.isEditorPath &&
|
||||||
|
!input &&
|
||||||
|
!keyup &&
|
||||||
|
event.key === 'f' &&
|
||||||
|
(event.ctrlKey || event.metaKey)
|
||||||
|
) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style)
|
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||||
else if (!input && keyup && event.key === '/') {
|
else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
|
||||||
|
// Record the actual character for display (varies by keyboard layout)
|
||||||
|
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||||
|
store.prefs.searchHotkey = event.key
|
||||||
|
}
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
headerMain.value!.closeSearch(event)
|
store.clearToast()
|
||||||
|
// Keep rename and other non-search inputs isolated from search behavior.
|
||||||
|
if (input && !searchInput) return
|
||||||
|
if (!path.value.isEditorPath) {
|
||||||
|
headerMain.value!.clearSearch(event)
|
||||||
|
}
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
}
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
else if (!input && keyup && event.key === 'Backspace') {
|
|
||||||
Router.back()
|
Router.back()
|
||||||
}
|
}
|
||||||
// Select all (toggle); keydown to precede and prevent builtin
|
// Select all (toggle); keydown to precede and prevent builtin
|
||||||
@@ -112,20 +269,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
else if (
|
else if (
|
||||||
!input &&
|
!input &&
|
||||||
keyup &&
|
keyup &&
|
||||||
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
|
(event.code === 'Backquote' ||
|
||||||
|
event.key === '1' ||
|
||||||
|
event.key === '2' ||
|
||||||
|
event.key === '3')
|
||||||
) {
|
) {
|
||||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||||
}
|
}
|
||||||
// Rename
|
// Rename
|
||||||
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
else if (
|
||||||
|
!input &&
|
||||||
|
c &&
|
||||||
|
keyup &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
(event.key === 'F2' || event.key === 'r')
|
||||||
|
) {
|
||||||
fileExplorer.cursorRename()
|
fileExplorer.cursorRename()
|
||||||
}
|
}
|
||||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||||
else if (!input && c && event.code === 'Space') {
|
else if (!input && c && event.code === 'Space') {
|
||||||
if (keyup && !event.altKey && !event.ctrlKey)
|
if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
|
||||||
fileExplorer.cursorSelect()
|
} else return
|
||||||
}
|
|
||||||
else return
|
|
||||||
/// We are handling this!
|
/// We are handling this!
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -133,26 +297,134 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
timer = null
|
timer = null
|
||||||
}
|
}
|
||||||
let f: any
|
let f: any
|
||||||
switch (arrow) {
|
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||||
case 'up': f = () => fileExplorer.up(event); break
|
if (arrow && !keyup) {
|
||||||
case 'down': f = () => fileExplorer.down(event); break
|
const focusSearch = () =>
|
||||||
case 'left': f = () => fileExplorer.left(event); break
|
(
|
||||||
case 'right': f = () => fileExplorer.right(event); break
|
document.querySelector('.headermain input[type="search"]') as HTMLElement
|
||||||
|
)?.focus()
|
||||||
|
const focusBreadcrumb = () =>
|
||||||
|
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||||
|
|
||||||
|
if (inBreadcrumb) {
|
||||||
|
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||||
|
if (arrow === 'up') {
|
||||||
|
focusSearch()
|
||||||
|
f = null
|
||||||
|
} else if (arrow === 'down') {
|
||||||
|
fileExplorer.focusFirst?.()
|
||||||
|
f = null
|
||||||
|
}
|
||||||
|
} else if (inHeader) {
|
||||||
|
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||||
|
const items = Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'.headermain button:not([tabindex="-1"]), .headermain input[type="search"], .headermain [tabindex="0"]'
|
||||||
|
)
|
||||||
|
) as HTMLElement[]
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||||
|
if (arrow === 'left' && idx > 0) {
|
||||||
|
items[idx - 1]?.focus()
|
||||||
|
f = null
|
||||||
|
} else if (arrow === 'right' && idx < items.length - 1) {
|
||||||
|
items[idx + 1]?.focus()
|
||||||
|
f = null
|
||||||
|
} else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||||
|
else if (arrow === 'down') {
|
||||||
|
focusBreadcrumb()
|
||||||
|
f = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File explorer: normal navigation with repeat
|
||||||
|
switch (arrow) {
|
||||||
|
case 'up':
|
||||||
|
f = () => fileExplorer.up(event)
|
||||||
|
break
|
||||||
|
case 'down':
|
||||||
|
f = () => fileExplorer.down(event)
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
f = () => fileExplorer.left(event)
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
f = () => fileExplorer.right(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
|
||||||
|
switch (paging) {
|
||||||
|
case 'PageUp':
|
||||||
|
f = () => fileExplorer.pageUp?.(event)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
f = () => fileExplorer.pageDown?.(event)
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
f = () => fileExplorer.home?.(event)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
f = () => fileExplorer.end?.(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f && !keyup) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
const t0 = 200,
|
||||||
|
tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
|
if (paging === 'Home' || paging === 'End') return
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = setInterval(f, tr)
|
||||||
|
}, t0 - tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', globalShortcutHandler)
|
// Use capture phase to handle events before they reach target elements
|
||||||
window.addEventListener('keyup', globalShortcutHandler)
|
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||||
|
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', globalShortcutHandler)
|
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||||
window.removeEventListener('keyup', globalShortcutHandler)
|
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Toast notifications - fixed at top center of viewport */
|
||||||
|
.toast-message {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.toast-message.status {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
footer > * {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
@@ -13,8 +13,8 @@
|
|||||||
--transition-time: 0.2s;
|
--transition-time: 0.2s;
|
||||||
/* The following are overridden by responsive layouts */
|
/* The following are overridden by responsive layouts */
|
||||||
--root-font-size: 1rem;
|
--root-font-size: 1rem;
|
||||||
--header-font-size: 1rem;
|
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
|
||||||
--header-height: 4rem;
|
--header-height: clamp(2.8rem, 10vw, 4rem);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
--header-color: #ccc;
|
--header-color: #ccc;
|
||||||
--input-background: var(--soft-color);
|
--input-background: var(--soft-color);
|
||||||
--input-color: #ddd;
|
--input-color: #ddd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (max-width: 600px) {
|
||||||
.size,
|
.size,
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
@media screen and (min-width: 1000px) {
|
@media screen and (min-width: 1000px) {
|
||||||
:root {
|
:root {
|
||||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||||
|
--header-font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 2000px) {
|
@media screen and (min-width: 2000px) {
|
||||||
@@ -43,45 +44,81 @@
|
|||||||
--root-font-size: 1.5rem;
|
--root-font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media screen and (max-height: 300px) {
|
|
||||||
:root {
|
|
||||||
--header-font-size: 15px; /* 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) {
|
@media screen and (orientation: landscape) and (min-width: 700px) {
|
||||||
/* Breadcrumbs and buttons side by side */
|
/* Breadcrumbs and buttons side by side */
|
||||||
:root {
|
|
||||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
|
||||||
}
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
header .headermain { order: 1; }
|
header .headermain {
|
||||||
header .breadcrumb { align-self: stretch; }
|
order: 1;
|
||||||
header .action-button {
|
}
|
||||||
width: 2em;
|
header .breadcrumb {
|
||||||
height: 2em;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Directory navigation slide transitions */
|
||||||
|
.transition-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-content {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-backward-enter-active {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-enter-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-from {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-to {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-from {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-to {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
:root {
|
:root {
|
||||||
--primary-color: black;
|
--primary-color: black;
|
||||||
@@ -91,8 +128,7 @@
|
|||||||
}
|
}
|
||||||
.headermain,
|
.headermain,
|
||||||
.menu,
|
.menu,
|
||||||
.rename-button,
|
.rename-button {
|
||||||
.suggest-gallery {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.breadcrumb > a {
|
.breadcrumb > a {
|
||||||
@@ -104,13 +140,14 @@
|
|||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
}
|
}
|
||||||
.breadcrumb > a::after {
|
.breadcrumb > a::after {
|
||||||
content: '/';
|
content: "/";
|
||||||
}
|
}
|
||||||
.breadcrumb svg {
|
.breadcrumb svg {
|
||||||
fill: black !important;
|
fill: black !important;
|
||||||
margin: 0 .5rem 0 1rem !important;
|
margin: 0 .5rem 0 1rem !important;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
|
position: static !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -130,7 +167,8 @@
|
|||||||
video::-webkit-media-controls {
|
video::-webkit-media-controls {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
tr, figure {
|
tr,
|
||||||
|
figure {
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
.selection {
|
.selection {
|
||||||
@@ -163,13 +201,18 @@ main {
|
|||||||
body {
|
body {
|
||||||
background-color: var(--primary-background);
|
background-color: var(--primary-background);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* Prevent any scrolling on body */
|
||||||
|
overflow: hidden;
|
||||||
|
/* Fallback for older browsers */
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
tbody .size,
|
tbody .size,
|
||||||
tbody .modified {
|
tbody .modified {
|
||||||
font-family: 'Roboto Mono';
|
font-family: "Roboto Mono";
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -215,14 +258,18 @@ table {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
height: 100vh;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* Allow flex child to shrink below content size */
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,47 +278,20 @@ header nav.headermain {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.spacer { flex-grow: 1 }
|
.spacer {
|
||||||
.smallgap { flex-shrink: 1; width: 2em }
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.smallgap {
|
||||||
|
flex-shrink: 1;
|
||||||
|
width: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
[data-tooltip]:hover:after {
|
|
||||||
z-index: 101;
|
|
||||||
content: attr(data-tooltip);
|
|
||||||
position: absolute;
|
|
||||||
font-size: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
border-radius: 3rem 0 3rem 0;
|
|
||||||
box-shadow: 0 0 1rem var(--accent-color);
|
|
||||||
transform: translate(calc(1rem + -50%), 150%);
|
|
||||||
background-color: var(--accent-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
white-space: pre;
|
|
||||||
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
|
||||||
}
|
|
||||||
.modified [data-tooltip]:hover:after {
|
|
||||||
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
|
|
||||||
}
|
|
||||||
@keyframes appearbriefly {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.error-message {
|
.error-message {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
.ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
|
||||||
|
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 452 B |
@@ -0,0 +1,185 @@
|
|||||||
|
// SVG icon index - all icons bundled together
|
||||||
|
import AddFile from './add-file.svg'
|
||||||
|
import AddFolder from './add-folder.svg'
|
||||||
|
import Arrow from './arrow.svg'
|
||||||
|
import ArrowsH from './arrows-h.svg'
|
||||||
|
import ArrowsV from './arrows-v.svg'
|
||||||
|
import Check from './check.svg'
|
||||||
|
import Code from './code.svg'
|
||||||
|
import Cog from './cog.svg'
|
||||||
|
import Copy from './copy.svg'
|
||||||
|
import CreateFile from './create-file.svg'
|
||||||
|
import CreateFolder from './create-folder.svg'
|
||||||
|
import Cross from './cross.svg'
|
||||||
|
import Disk from './disk.svg'
|
||||||
|
import Download from './download.svg'
|
||||||
|
import Exclamation from './exclamation.svg'
|
||||||
|
import Eye from './eye.svg'
|
||||||
|
import Find from './find.svg'
|
||||||
|
import Fullscreen from './fullscreen.svg'
|
||||||
|
import Github from './github.svg'
|
||||||
|
import Home from './home.svg'
|
||||||
|
import Info from './info.svg'
|
||||||
|
import Link from './link.svg'
|
||||||
|
import Logo from './logo.svg'
|
||||||
|
import Loop from './loop.svg'
|
||||||
|
import Menu from './menu.svg'
|
||||||
|
import Next from './next.svg'
|
||||||
|
import Open from './open.svg'
|
||||||
|
import Paste from './paste.svg'
|
||||||
|
import Pause from './pause.svg'
|
||||||
|
import Pencil from './pencil.svg'
|
||||||
|
import Play from './play.svg'
|
||||||
|
import Plus from './plus.svg'
|
||||||
|
import Previous from './previous.svg'
|
||||||
|
import Reload from './reload.svg'
|
||||||
|
import Rename from './rename.svg'
|
||||||
|
import Scissors from './scissors.svg'
|
||||||
|
import Shuffle from './shuffle.svg'
|
||||||
|
import Signin from './signin.svg'
|
||||||
|
import Signout from './signout.svg'
|
||||||
|
import Skip from './skip.svg'
|
||||||
|
import Spinner from './spinner.svg'
|
||||||
|
import Stop from './stop.svg'
|
||||||
|
import Trash from './trash.svg'
|
||||||
|
import Triangle from './triangle.svg'
|
||||||
|
import Unfullscreen from './unfullscreen.svg'
|
||||||
|
import UpArrow from './up-arrow.svg'
|
||||||
|
import UploadCloud from './upload-cloud.svg'
|
||||||
|
import UserCog from './user-cog.svg'
|
||||||
|
import User from './user.svg'
|
||||||
|
import VolumeHigh from './volume-high.svg'
|
||||||
|
import VolumeLow from './volume-low.svg'
|
||||||
|
import VolumeMedium from './volume-medium.svg'
|
||||||
|
import VolumeMute from './volume-mute.svg'
|
||||||
|
import WindowCross from './window-cross.svg'
|
||||||
|
import Window from './window.svg'
|
||||||
|
import Wordwrap from './wordwrap.svg'
|
||||||
|
import Zoomin from './zoomin.svg'
|
||||||
|
import Zoomout from './zoomout.svg'
|
||||||
|
|
||||||
|
// Named exports for direct imports
|
||||||
|
export {
|
||||||
|
AddFile,
|
||||||
|
AddFolder,
|
||||||
|
Arrow,
|
||||||
|
ArrowsH,
|
||||||
|
ArrowsV,
|
||||||
|
Check,
|
||||||
|
Code,
|
||||||
|
Cog,
|
||||||
|
Copy,
|
||||||
|
CreateFile,
|
||||||
|
CreateFolder,
|
||||||
|
Cross,
|
||||||
|
Disk,
|
||||||
|
Download,
|
||||||
|
Exclamation,
|
||||||
|
Eye,
|
||||||
|
Find,
|
||||||
|
Fullscreen,
|
||||||
|
Github,
|
||||||
|
Home,
|
||||||
|
Info,
|
||||||
|
Link,
|
||||||
|
Logo,
|
||||||
|
Loop,
|
||||||
|
Menu,
|
||||||
|
Next,
|
||||||
|
Open,
|
||||||
|
Paste,
|
||||||
|
Pause,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Previous,
|
||||||
|
Reload,
|
||||||
|
Rename,
|
||||||
|
Scissors,
|
||||||
|
Shuffle,
|
||||||
|
Signin,
|
||||||
|
Signout,
|
||||||
|
Skip,
|
||||||
|
Spinner,
|
||||||
|
Stop,
|
||||||
|
Trash,
|
||||||
|
Triangle,
|
||||||
|
Unfullscreen,
|
||||||
|
UpArrow,
|
||||||
|
UploadCloud,
|
||||||
|
UserCog,
|
||||||
|
User,
|
||||||
|
VolumeHigh,
|
||||||
|
VolumeLow,
|
||||||
|
VolumeMedium,
|
||||||
|
VolumeMute,
|
||||||
|
WindowCross,
|
||||||
|
Window,
|
||||||
|
Wordwrap,
|
||||||
|
Zoomin,
|
||||||
|
Zoomout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||||
|
export const icons = {
|
||||||
|
'add-file': AddFile,
|
||||||
|
'add-folder': AddFolder,
|
||||||
|
arrow: Arrow,
|
||||||
|
'arrows-h': ArrowsH,
|
||||||
|
'arrows-v': ArrowsV,
|
||||||
|
check: Check,
|
||||||
|
code: Code,
|
||||||
|
cog: Cog,
|
||||||
|
copy: Copy,
|
||||||
|
'create-file': CreateFile,
|
||||||
|
'create-folder': CreateFolder,
|
||||||
|
cross: Cross,
|
||||||
|
disk: Disk,
|
||||||
|
download: Download,
|
||||||
|
exclamation: Exclamation,
|
||||||
|
eye: Eye,
|
||||||
|
find: Find,
|
||||||
|
fullscreen: Fullscreen,
|
||||||
|
github: Github,
|
||||||
|
home: Home,
|
||||||
|
info: Info,
|
||||||
|
link: Link,
|
||||||
|
logo: Logo,
|
||||||
|
loop: Loop,
|
||||||
|
menu: Menu,
|
||||||
|
next: Next,
|
||||||
|
open: Open,
|
||||||
|
paste: Paste,
|
||||||
|
pause: Pause,
|
||||||
|
pencil: Pencil,
|
||||||
|
play: Play,
|
||||||
|
plus: Plus,
|
||||||
|
previous: Previous,
|
||||||
|
reload: Reload,
|
||||||
|
rename: Rename,
|
||||||
|
scissors: Scissors,
|
||||||
|
shuffle: Shuffle,
|
||||||
|
signin: Signin,
|
||||||
|
signout: Signout,
|
||||||
|
skip: Skip,
|
||||||
|
spinner: Spinner,
|
||||||
|
stop: Stop,
|
||||||
|
trash: Trash,
|
||||||
|
triangle: Triangle,
|
||||||
|
unfullscreen: Unfullscreen,
|
||||||
|
'up-arrow': UpArrow,
|
||||||
|
'upload-cloud': UploadCloud,
|
||||||
|
'user-cog': UserCog,
|
||||||
|
user: User,
|
||||||
|
'volume-high': VolumeHigh,
|
||||||
|
'volume-low': VolumeLow,
|
||||||
|
'volume-medium': VolumeMedium,
|
||||||
|
'volume-mute': VolumeMute,
|
||||||
|
'window-cross': WindowCross,
|
||||||
|
window: Window,
|
||||||
|
wordwrap: Wordwrap,
|
||||||
|
zoomin: Zoomin,
|
||||||
|
zoomout: Zoomout
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons
|
||||||
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 492 B |
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name="about" title="">
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-logo-pane">
|
||||||
|
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="about-details">
|
||||||
|
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
|
||||||
|
<p class="about-link">
|
||||||
|
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<div class="dialog-buttons about-actions">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input id="close" type="reset" value="Close" class="button" @click="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import logoUrl from '@/assets/logo-square.svg?url'
|
||||||
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const softwareVersion = computed(() => store.server.version || 'unknown')
|
||||||
|
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
|
||||||
|
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(#about.modal-dialog) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
width: min(35rem, 92vw);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin: -1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
margin: 0.65rem 0 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.about-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: min(24rem, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name="accessdenied" title="">
|
||||||
|
<div class="access-denied">
|
||||||
|
<p class="icon">⛔</p>
|
||||||
|
<p class="message">Access Denied</p>
|
||||||
|
<button @click="reload" class="button">Reload</button>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.access-denied {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem !important;
|
||||||
|
}
|
||||||
|
.access-denied .icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
.access-denied .message {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,65 +8,100 @@
|
|||||||
@focus=focusCurrent
|
@focus=focusCurrent
|
||||||
tabindex=0
|
tabindex=0
|
||||||
>
|
>
|
||||||
<a href="#/"
|
<a :href="`/#${urlAt(0)}`"
|
||||||
:ref="el => setLinkRef(0, el)"
|
:ref="el => setLinkRef(0, el)"
|
||||||
class="home"
|
class="home"
|
||||||
:class="{ current: !!isCurrent(0) }"
|
:class="{ current: !!isCurrent(0) }"
|
||||||
:aria-current="isCurrent(0)"
|
:aria-current="isCurrent(0)"
|
||||||
@click.prevent="navigate(0)"
|
@click.prevent="navigate(0)"
|
||||||
title="/"
|
@mouseenter="homeTooltip?.startHover"
|
||||||
|
@mousemove="homeTooltip?.updatePosition"
|
||||||
|
@mouseleave="homeTooltip?.endHover"
|
||||||
>
|
>
|
||||||
<component :is="home" />
|
<component :is="home" />
|
||||||
|
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
<template v-for="(location, index) in longest" :key="index">
|
<template v-for="(location, index) in longest" :key="index">
|
||||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
<a :href="`/#${urlAt(index + 1)}`"
|
||||||
:class="{ current: !!isCurrent(index + 1) }"
|
:class="{ current: !!isCurrent(index + 1) }"
|
||||||
:aria-current="isCurrent(index + 1)"
|
:aria-current="isCurrent(index + 1)"
|
||||||
@click.prevent="navigate(index + 1)"
|
@click.prevent="navigate(index + 1)"
|
||||||
:ref="el => setLinkRef(index + 1, el)"
|
:ref="el => setLinkRef(index + 1, el)"
|
||||||
:title="`/${longest.slice(0, index + 1).join('/')}`"
|
@mouseenter="pathTooltips.get(index)?.startHover"
|
||||||
>{{ location }}</a>
|
@mousemove="pathTooltips.get(index)?.updatePosition"
|
||||||
|
@mouseleave="pathTooltips.get(index)?.endHover"
|
||||||
|
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import home from '@/assets/svg/home.svg'
|
import { Home } from '@/assets/svg'
|
||||||
|
import { exists } from '@/utils/fileutil'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { exists } from '@/utils/fileutil'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const home = Home
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const links = [] as Array<HTMLElement>
|
const links = [] as Array<HTMLElement>
|
||||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
const setLinkRef = (index: number, el: any) => {
|
||||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
if (el) links[index] = el
|
||||||
|
}
|
||||||
|
onBeforeUpdate(() => {
|
||||||
|
links.length = 1
|
||||||
|
}) // 1 to keep home
|
||||||
|
|
||||||
|
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
||||||
|
const setPathTooltipRef = (index: number, el: any) => {
|
||||||
|
if (el) pathTooltips.value.set(index, el)
|
||||||
|
else pathTooltips.value.delete(index)
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
|
links?: Array<string>
|
||||||
primary?: boolean
|
primary?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const longest = ref<Array<string>>([])
|
const longest = ref<Array<string>>([])
|
||||||
|
const longestLinks = ref<Array<string>>(['/'])
|
||||||
|
|
||||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
const defaultLinks = (segments: Array<string>) => [
|
||||||
|
'/',
|
||||||
|
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
|
||||||
|
]
|
||||||
|
|
||||||
|
const isCurrent = (index: number) =>
|
||||||
|
index == props.path.length ? 'location' : undefined
|
||||||
|
|
||||||
const focusCurrent = () => {
|
const focusCurrent = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const index = props.path.length
|
const index = props.path.length
|
||||||
if (index < links.length) links[index].focus()
|
if (index < links.length) links[index]!.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlAt = (index: number) => {
|
||||||
|
const explicit = longestLinks.value[index]
|
||||||
|
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
|
||||||
|
}
|
||||||
|
|
||||||
const navigate = (index: number) => {
|
const navigate = (index: number) => {
|
||||||
const link = links[index]
|
const link = links[index]
|
||||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
const url = urlAt(index)
|
||||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
if (isCurrent(index)) {
|
||||||
|
longest.value.splice(index)
|
||||||
|
longestLinks.value.splice(index + 1)
|
||||||
|
router.push(u)
|
||||||
|
}
|
||||||
// Moving along breadcrumbs doesn't create new history
|
// Moving along breadcrumbs doesn't create new history
|
||||||
else if (long.startsWith(browser)) router.replace(u)
|
else if (long.startsWith(browser)) router.replace(u)
|
||||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||||
@@ -80,27 +115,33 @@ const move = (dir: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
const currentLinks = props.links ?? defaultLinks(props.path)
|
||||||
const longcut = longest.value.slice(0, props.path.length)
|
const longcut = longest.value.slice(0, props.path.length)
|
||||||
const same = longcut.every((value, index) => value === props.path[index])
|
const same = longcut.every((value, index) => value === props.path[index])
|
||||||
// Navigated out of previous path, reset longest to current
|
// Navigated out of previous path, reset longest to current
|
||||||
if (!same) longest.value = props.path
|
if (!same) {
|
||||||
else if (props.path.length > longcut.length) {
|
longest.value = props.path
|
||||||
|
longestLinks.value = currentLinks
|
||||||
|
} else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
}
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
else {
|
} else {
|
||||||
// Prune deleted folders from longest
|
// Prune deleted folders from longest
|
||||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||||
if (!exists(longest.value.slice(0, i + 1))) {
|
if (!exists(longest.value.slice(0, i + 1))) {
|
||||||
longest.value = longest.value.slice(0, i)
|
longest.value = longest.value.slice(0, i)
|
||||||
|
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
}
|
}
|
||||||
// If needed, focus primary navigation to new location
|
// If needed, focus primary navigation to new location
|
||||||
if (props.primary) nextTick(() => {
|
if (props.primary)
|
||||||
const act = document.activeElement as HTMLElement
|
nextTick(() => {
|
||||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
const act = document.activeElement as HTMLElement
|
||||||
})
|
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" ref="tooltipEl" class="cursor-tooltip" :style="tooltipStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Global activation state - shared across all instances
|
||||||
|
let globalActive = false
|
||||||
|
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Track recent touch to suppress touch-triggered mouse events
|
||||||
|
let lastTouchTime = 0
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string
|
||||||
|
delay?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const mouseX = ref(0)
|
||||||
|
const mouseY = ref(0)
|
||||||
|
const tooltipWidth = ref(0)
|
||||||
|
const tooltipHeight = ref(0)
|
||||||
|
const tooltipEl = ref<HTMLElement | null>(null)
|
||||||
|
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastMoveX = 0
|
||||||
|
let lastMoveY = 0
|
||||||
|
|
||||||
|
// Movement threshold (pixels) - cursor must settle within this radius
|
||||||
|
const SETTLE_THRESHOLD = 8
|
||||||
|
|
||||||
|
const tooltipStyle = computed(() => {
|
||||||
|
// Constrain to viewport
|
||||||
|
const pad = 8
|
||||||
|
let x = mouseX.value
|
||||||
|
let y = mouseY.value
|
||||||
|
|
||||||
|
// Only constrain if we've measured the tooltip
|
||||||
|
if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
|
||||||
|
// Adjust horizontal position if tooltip would overflow right edge
|
||||||
|
if (x + tooltipWidth.value + pad > window.innerWidth) {
|
||||||
|
x = window.innerWidth - tooltipWidth.value - pad
|
||||||
|
}
|
||||||
|
// Adjust vertical position if tooltip would overflow bottom edge
|
||||||
|
if (y + tooltipHeight.value + pad > window.innerHeight) {
|
||||||
|
y = window.innerHeight - tooltipHeight.value - pad
|
||||||
|
}
|
||||||
|
// Don't go past left/top edges
|
||||||
|
x = Math.max(pad, x)
|
||||||
|
y = Math.max(pad, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track touch events globally to suppress touch-simulated mouse events
|
||||||
|
const onTouchStart = () => { lastTouchTime = Date.now() }
|
||||||
|
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
|
||||||
|
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
|
||||||
|
|
||||||
|
// Check if event is likely from touch (touch happened within last 500ms)
|
||||||
|
const isTouchEvent = () => Date.now() - lastTouchTime < 500
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
visible.value = true
|
||||||
|
globalActive = true
|
||||||
|
// Measure tooltip after it renders
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (tooltipEl.value) {
|
||||||
|
tooltipWidth.value = tooltipEl.value.offsetWidth
|
||||||
|
tooltipHeight.value = tooltipEl.value.offsetHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTooltip = () => {
|
||||||
|
if (settleTimer) clearTimeout(settleTimer)
|
||||||
|
if (globalDeactivateTimer) {
|
||||||
|
clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = null
|
||||||
|
}
|
||||||
|
const delay = globalActive ? 0 : (props.delay ?? 900)
|
||||||
|
settleTimer = setTimeout(showTooltip, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startHover = (e: MouseEvent) => {
|
||||||
|
// Ignore touch-simulated mouse events
|
||||||
|
if (isTouchEvent()) return
|
||||||
|
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = (e: MouseEvent) => {
|
||||||
|
// Ignore touch-simulated mouse events
|
||||||
|
if (isTouchEvent()) return
|
||||||
|
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
|
||||||
|
// If tooltip is already visible, just update position
|
||||||
|
if (visible.value) return
|
||||||
|
|
||||||
|
const dx = e.clientX - lastMoveX
|
||||||
|
const dy = e.clientY - lastMoveY
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
// If cursor moved beyond threshold, reset settle timer
|
||||||
|
if (distance > SETTLE_THRESHOLD) {
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer)
|
||||||
|
settleTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule tooltip when cursor settles
|
||||||
|
if (!settleTimer) {
|
||||||
|
scheduleTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endHover = () => {
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer)
|
||||||
|
settleTimer = null
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
// Deactivate global state after a short delay if no new tooltip started
|
||||||
|
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = setTimeout(() => {
|
||||||
|
globalActive = false
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
startHover,
|
||||||
|
updatePosition,
|
||||||
|
endHover,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cursor-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border-radius: 3rem 0 3rem 0;
|
||||||
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<template>
|
||||||
|
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||||
|
<div
|
||||||
|
ref="widgetRef"
|
||||||
|
class="disk-space-widget"
|
||||||
|
:class="{ expanded: isExpanded }"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<defs>
|
||||||
|
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
|
||||||
|
</filter>
|
||||||
|
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#93e" />
|
||||||
|
<stop offset="100%" stop-color="#82d" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#d9f" />
|
||||||
|
<stop offset="100%" stop-color="#c8e" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
|
||||||
|
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="showOtherCategory ? 'url(#otherGradient)' : freeColor" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
|
||||||
|
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
|
||||||
|
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g ref="labelsRef" class="pie-labels">
|
||||||
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||||
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
|
<text v-if="showOtherCategory" :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
|
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||||
|
<path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text class="pie-label-sub" fill="#93e">
|
||||||
|
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
|
||||||
|
</text>
|
||||||
|
<text class="pie-label-sub" :fill="freeColor">
|
||||||
|
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||||
|
</text>
|
||||||
|
<text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
|
||||||
|
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const widgetRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const labelsRef = ref<SVGGElement | null>(null)
|
||||||
|
const centerLabelRef = ref<SVGTextElement | null>(null)
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
let animationFrame: number | null = null
|
||||||
|
|
||||||
|
const BASE_SIZE = 48
|
||||||
|
const EXPANDED_SCALE = 320 / 48
|
||||||
|
const ANIM_DURATION = 200
|
||||||
|
const containerPos = ref({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
const formatGB = (bytes: number) => {
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024)
|
||||||
|
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
|
||||||
|
const fmtSize = (bytes: number, angle: number) => {
|
||||||
|
const s = formatGB(bytes)
|
||||||
|
const a = Math.abs(angle % 180)
|
||||||
|
return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
|
||||||
|
if (parts[0] && parts[0].length <= maxLen) return parts[0]
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherBytes = computed(() => Math.max(0, store.space.used - store.space.allocated))
|
||||||
|
const showOtherCategory = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
||||||
|
})
|
||||||
|
const freeSliceBytes = computed(() =>
|
||||||
|
showOtherCategory.value
|
||||||
|
? store.space.free
|
||||||
|
: Math.max(0, store.space.disk - store.space.allocated)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
|
const storageMaxLen = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 10
|
||||||
|
// Sector spans in degrees
|
||||||
|
const storageSpan = (s.allocated / s.disk) * 360
|
||||||
|
const freeSpan = (freeSliceBytes.value / s.disk) * 360
|
||||||
|
const otherSpan = (otherBytes.value / s.disk) * 360
|
||||||
|
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||||
|
const gapToFree = (storageSpan + freeSpan) / 2
|
||||||
|
const minGap = showOtherCategory.value
|
||||||
|
? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
|
||||||
|
: gapToFree
|
||||||
|
// Allow longer names when there's sufficient gap to both neighbors
|
||||||
|
if (minGap > 70) return 18
|
||||||
|
if (minGap > 55) return 14
|
||||||
|
return 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const storageName = computed(() => {
|
||||||
|
const name = store.server.name || 'stored'
|
||||||
|
const maxLen = storageMaxLen.value
|
||||||
|
// Use full name if it fits within the available space
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return truncateLabel(name, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const TAU = 2 * Math.PI
|
||||||
|
|
||||||
|
const pieCx = 75
|
||||||
|
const pieCy = 75
|
||||||
|
const pieRadius = 55
|
||||||
|
const holeRadius = pieRadius * 0.38
|
||||||
|
const ringWidth = pieRadius - holeRadius
|
||||||
|
const midRadius = (pieRadius + holeRadius) / 2
|
||||||
|
const CIRC = TAU * midRadius
|
||||||
|
|
||||||
|
const pieStorageDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeOffsetVal = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 0
|
||||||
|
return -(s.allocated / s.disk) * CIRC
|
||||||
|
})
|
||||||
|
|
||||||
|
const freeColor = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return '#6c6'
|
||||||
|
const freePct = s.free / s.disk
|
||||||
|
if (freePct > 0.25) return '#5b5'
|
||||||
|
if (freePct > 0.1) return '#ff0'
|
||||||
|
return '#f00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const PIE_RADIUS = 55
|
||||||
|
const LABEL_RADIUS = 62
|
||||||
|
|
||||||
|
const getPoint = (angle: number, radius: number) => {
|
||||||
|
const rad = (TAU * (angle - 90)) / 360
|
||||||
|
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectorInfo = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk)
|
||||||
|
return {
|
||||||
|
storage: { angle: 45, pct: 0.25 },
|
||||||
|
free: { angle: 180, pct: 0.5 },
|
||||||
|
other: { angle: 270, pct: 0.25 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePct = s.allocated / s.disk
|
||||||
|
const freePct = freeSliceBytes.value / s.disk
|
||||||
|
const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
|
||||||
|
|
||||||
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
|
const freeStart = storagePct * 360
|
||||||
|
const freeAngle = freeStart + freePct * 180
|
||||||
|
const otherStart = (storagePct + freePct) * 360
|
||||||
|
const otherAngle = otherStart + otherPct * 180
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage: { angle: storageAngle, pct: storagePct },
|
||||||
|
free: { angle: freeAngle, pct: freePct },
|
||||||
|
other: { angle: otherAngle, pct: otherPct }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawAngles = computed(() => ({
|
||||||
|
storage: sectorInfo.value.storage.angle,
|
||||||
|
free: sectorInfo.value.free.angle,
|
||||||
|
...(showOtherCategory.value ? { other: sectorInfo.value.other.angle } : {})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
||||||
|
const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
|
||||||
|
|
||||||
|
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||||
|
const storageInnerPos = computed(() =>
|
||||||
|
getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
|
||||||
|
)
|
||||||
|
const freeInnerPos = computed(() =>
|
||||||
|
getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)
|
||||||
|
)
|
||||||
|
const otherInnerPos = computed(() =>
|
||||||
|
getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collision avoidance for curved name labels
|
||||||
|
const labelLengths = computed(() => ({
|
||||||
|
storage: storageName.value.length,
|
||||||
|
free: 4,
|
||||||
|
...(showOtherCategory.value ? { other: 5 } : {})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getGapForPair = (len1: number, len2: number) => {
|
||||||
|
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedLabelAngles = computed(() => {
|
||||||
|
const angles = rawAngles.value
|
||||||
|
const lens = labelLengths.value
|
||||||
|
const labels = [
|
||||||
|
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||||
|
{ id: 'free', angle: angles.free, len: lens.free },
|
||||||
|
...(showOtherCategory.value
|
||||||
|
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
labels.sort((a, b) => a.angle - b.angle)
|
||||||
|
|
||||||
|
for (let iterations = 0; iterations < 15; iterations++) {
|
||||||
|
let moved = false
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
const current = labels[i]!
|
||||||
|
const next = labels[(i + 1) % labels.length]!
|
||||||
|
let angleDiff = next.angle - current.angle
|
||||||
|
if (angleDiff < 0) angleDiff += 360
|
||||||
|
const requiredGap = getGapForPair(current.len, next.len)
|
||||||
|
if (angleDiff < requiredGap) {
|
||||||
|
const push = (requiredGap - angleDiff) / 2
|
||||||
|
current.angle = (current.angle - push + 360) % 360
|
||||||
|
next.angle = (next.angle + push) % 360
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, number> = {}
|
||||||
|
for (const l of labels) result[l.id] = l.angle
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||||
|
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||||
|
const radius = LABEL_RADIUS
|
||||||
|
// Scale arc span based on label length: ~6° per character, minimum 45°
|
||||||
|
const arcSpan = Math.max(45, labelLen * 6)
|
||||||
|
const isBottom = centerAngle > 90 && centerAngle <= 270
|
||||||
|
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
||||||
|
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
||||||
|
const start = getPoint(startAngle, radius)
|
||||||
|
const end = getPoint(endAngle, radius)
|
||||||
|
const sweep = isBottom ? 0 : 1
|
||||||
|
return {
|
||||||
|
id: `label-path-${id}`,
|
||||||
|
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageLabelPath = computed(() =>
|
||||||
|
createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
|
||||||
|
)
|
||||||
|
const freeLabelPath = computed(() =>
|
||||||
|
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||||
|
)
|
||||||
|
const otherLabelPath = computed(() =>
|
||||||
|
createArcPath(
|
||||||
|
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
|
||||||
|
'other',
|
||||||
|
5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||||
|
|
||||||
|
const applyAnimState = (t: number, opacity: number) => {
|
||||||
|
const widget = widgetRef.value
|
||||||
|
const labels = labelsRef.value
|
||||||
|
const centerLabel = centerLabelRef.value
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
const scale = 1 + (EXPANDED_SCALE - 1) * t
|
||||||
|
// Move top-right corner of widget to top-right corner of viewport
|
||||||
|
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
|
||||||
|
const targetY = -containerPos.value.top
|
||||||
|
|
||||||
|
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
|
||||||
|
if (labels) labels.style.opacity = String(opacity)
|
||||||
|
if (centerLabel) centerLabel.style.opacity = String(opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const elapsed = now - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||||
|
const t = expanding ? eased : 1 - eased
|
||||||
|
applyAnimState(t, t) // opacity follows position
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
animationFrame = null
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expand = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
isExpanded.value = true
|
||||||
|
animate(ANIM_DURATION, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapse = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
animate(ANIM_DURATION, false, () => {
|
||||||
|
isExpanded.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isExpanded.value) collapse()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
// Initialize labels as hidden
|
||||||
|
if (labelsRef.value) labelsRef.value.style.opacity = '0'
|
||||||
|
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.disk-space-container {
|
||||||
|
position: relative;
|
||||||
|
width: 3em;
|
||||||
|
height: 3em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
will-change: transform;
|
||||||
|
filter: brightness(0.85);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget:hover,
|
||||||
|
.disk-space-widget:focus {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded {
|
||||||
|
pointer-events: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded:hover,
|
||||||
|
.disk-space-widget.expanded:focus {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-hitarea {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-inner {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 0.5px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-center-label {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant: small-caps;
|
||||||
|
text-transform: lowercase;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 1px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
<SvgButton name="download" tooltip="Download" @click="download" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
import { reactive } from 'vue';
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { zipName } from '@/utils/fileutil'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
@@ -23,9 +24,9 @@ const status_init = {
|
|||||||
filename: '',
|
filename: '',
|
||||||
filesize: 0,
|
filesize: 0,
|
||||||
filepos: 0,
|
filepos: 0,
|
||||||
status: 'idle',
|
status: 'idle'
|
||||||
}
|
}
|
||||||
store.dprogress = {...status_init}
|
store.dprogress = { ...status_init }
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||||
// Reset
|
// Reset
|
||||||
@@ -33,8 +34,8 @@ setInterval(() => {
|
|||||||
store.dprogress.statdur = 1
|
store.dprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
// Running average by decay
|
||||||
store.dprogress.statbytes *= .9
|
store.dprogress.statbytes *= 0.9
|
||||||
store.dprogress.statdur *= .9
|
store.dprogress.statdur *= 0.9
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
@@ -43,10 +44,9 @@ const statReset = () => {
|
|||||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||||
}
|
}
|
||||||
const cancelDownloads = () => {
|
const cancelDownloads = () => {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const linkdl = (href: string) => {
|
const linkdl = (href: string) => {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = href
|
a.href = href
|
||||||
@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create directory', hdir, error)
|
console.error('Failed to create directory', hdir, error)
|
||||||
return
|
throw new Error(`Failed to create directory ${hdir}: ${error}`)
|
||||||
}
|
}
|
||||||
console.log('Created', hdir)
|
console.log('Created', hdir)
|
||||||
}
|
}
|
||||||
@@ -90,59 +90,69 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
fileHandle = await h.getFileHandle(name, { create: true })
|
fileHandle = await h.getFileHandle(name, { create: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||||
return
|
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
|
||||||
}
|
}
|
||||||
const writable = await fileHandle.createWritable()
|
try {
|
||||||
const url = `/files/${rel}`
|
const writable = await fileHandle.createWritable()
|
||||||
console.log('Fetching', url)
|
const url = `/files/${rel}`
|
||||||
const res = await fetch(url)
|
console.log('Fetching', url)
|
||||||
if (!res.ok) {
|
const res = await apiFetch(url)
|
||||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||||
}
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||||
if (res.body) {
|
|
||||||
++store.dprogress.fileidx
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
await writable.truncate(0)
|
|
||||||
store.error = "Direct download."
|
|
||||||
store.dprogress.tlast = Date.now()
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
await writable.write(value)
|
|
||||||
const now = Date.now()
|
|
||||||
const size = value.byteLength
|
|
||||||
store.dprogress.xfer += size
|
|
||||||
store.dprogress.filepos += size
|
|
||||||
store.dprogress.statbytes += size
|
|
||||||
store.dprogress.statdur += now - store.dprogress.tlast
|
|
||||||
store.dprogress.tlast = now
|
|
||||||
}
|
}
|
||||||
|
if (res.body) {
|
||||||
|
++store.dprogress.fileidx
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
await writable.truncate(0)
|
||||||
|
store.dprogress.tlast = Date.now()
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
await writable.write(value)
|
||||||
|
const now = Date.now()
|
||||||
|
const size = value.byteLength
|
||||||
|
store.dprogress.xfer += size
|
||||||
|
store.dprogress.filepos += size
|
||||||
|
store.dprogress.statbytes += size
|
||||||
|
store.dprogress.statdur += now - store.dprogress.tlast
|
||||||
|
store.dprogress.tlast = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writable.close()
|
||||||
|
console.log('Saved', hdir + name)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write file', hdir + name, error)
|
||||||
|
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
|
||||||
}
|
}
|
||||||
await writable.close()
|
|
||||||
console.log('Saved', hdir + name)
|
|
||||||
}
|
}
|
||||||
statReset()
|
statReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = async () => {
|
const zipdl = (sel: SelectedItems) => {
|
||||||
|
const items = sel.keys.map(k => sel.docs[k]!)
|
||||||
|
const name = zipName(items)
|
||||||
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
|
store.showToast(`Downloading ${name}.zip`)
|
||||||
|
store.selected.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const download = async (e: MouseEvent) => {
|
||||||
const sel = store.selectedFiles
|
const sel = store.selectedFiles
|
||||||
console.log('Download', sel)
|
|
||||||
if (sel.keys.length === 0) {
|
if (sel.keys.length === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
store.showToast('No existing files selected')
|
||||||
store.error = 'No existing files selected'
|
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Plain old a href download if only one file (ignoring any folders)
|
// Single file: direct download
|
||||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
store.error = "Single file via browser downloads"
|
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
return linkdl(`/files/${files[0]![1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Alt+click: download to folder (hidden feature)
|
||||||
if ('showDirectoryPicker' in window) {
|
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const handle = await window.showDirectoryPicker({
|
const handle = await window.showDirectoryPicker({
|
||||||
@@ -151,19 +161,15 @@ const download = async () => {
|
|||||||
})
|
})
|
||||||
await filesystemdl(sel, handle)
|
await filesystemdl(sel, handle)
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Download to folder aborted', e)
|
console.error('Download to folder failed', e)
|
||||||
|
store.showToast('Download to folder failed')
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Default: ZIP download
|
||||||
console.log("Falling back to zip download")
|
zipdl(sel)
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
|
||||||
store.error = "Downloading as ZIP via browser downloads"
|
|
||||||
store.selected.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="showEmpty" class="empty-container">
|
||||||
<component :is="cog" class="cog"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="!store.connected">No Connection</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
|
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
|
||||||
<p v-else-if="store.query">No matches!</p>
|
<p v-else-if="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
<p v-else>Empty folder</p>
|
<p v-else>Empty folder</p>
|
||||||
@@ -10,19 +11,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps } from 'vue'
|
import { Cog } from '@/assets/svg'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import cog from '@/assets/svg/cog.svg'
|
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: string[],
|
path: string[]
|
||||||
documents: Document[],
|
documents: Document[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showEmpty = computed(() => {
|
||||||
|
const loc = props.path.join('/')
|
||||||
|
const hasVisibleGhost = store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return g.loc === loc && !store.hiddenPaths.has(full)
|
||||||
|
})
|
||||||
|
|
||||||
|
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
@@ -35,4 +57,7 @@ svg.cog {
|
|||||||
filter: drop-shadow(0 0 1rem black);
|
filter: drop-shadow(0 0 1rem black);
|
||||||
fill: #888;
|
fill: #888;
|
||||||
}
|
}
|
||||||
|
svg.cog.stopped {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,85 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<table v-if="props.documents.length || editing">
|
<div class="file-explorer">
|
||||||
<thead>
|
<table v-if="props.documents.length || editing">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="selection">
|
<tr>
|
||||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
<th class="selection">
|
||||||
</th>
|
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
</th>
|
||||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||||
<th class="menu"></th>
|
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||||
</tr>
|
<th class="menu"></th>
|
||||||
</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>
|
|
||||||
<FileModified :doc=editing :key=nowkey />
|
|
||||||
<FileSize :doc=editing />
|
|
||||||
<td class="menu"></td>
|
|
||||||
</tr>
|
|
||||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
|
||||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
|
||||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
<tr
|
<tbody>
|
||||||
:id="`file-${doc.key}`"
|
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
|
<td class="selection"></td>
|
||||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
|
||||||
@contextmenu.prevent="contextMenu($event, doc)"
|
|
||||||
>
|
|
||||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
tabindex="-1"
|
|
||||||
:checked="store.selected.has(doc.key)"
|
|
||||||
@change="
|
|
||||||
($event.target as HTMLInputElement).checked
|
|
||||||
? store.selected.add(doc.key)
|
|
||||||
: store.selected.delete(doc.key)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<template v-if="editing === doc">
|
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
|
||||||
{{ doc.name }}
|
|
||||||
</a>
|
|
||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<FileModified :doc=doc :key=nowkey />
|
|
||||||
<FileSize :doc=doc />
|
|
||||||
<td class="menu">
|
|
||||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
|
||||||
</td>
|
</td>
|
||||||
|
<FileModified :doc=editing :now=nowkey />
|
||||||
|
<FileSize :doc=editing />
|
||||||
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||||
<tr class="summary" v-if="props.documents.length > 1">
|
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
</tr>
|
||||||
<td class="menu"></td>
|
|
||||||
</tr>
|
<tr
|
||||||
</tbody>
|
:id="`file-${doc.key}`"
|
||||||
</table>
|
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||||
|
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||||
|
@contextmenu.prevent="contextMenu($event, doc)"
|
||||||
|
>
|
||||||
|
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tabindex="-1"
|
||||||
|
:checked="store.selected.has(doc.key)"
|
||||||
|
@change="
|
||||||
|
($event.target as HTMLInputElement).checked
|
||||||
|
? store.selected.add(doc.key)
|
||||||
|
: store.selected.delete(doc.key)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="name">
|
||||||
|
<template v-if="editing === doc">
|
||||||
|
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||||
|
{{ doc.name }}
|
||||||
|
</a>
|
||||||
|
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<FileModified :doc=doc :now=nowkey />
|
||||||
|
<FileSize :doc=doc />
|
||||||
|
<td class="menu">
|
||||||
|
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr class="summary" v-if="props.documents.length > 1">
|
||||||
|
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||||
|
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||||
|
<td class="menu"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch
|
||||||
|
} from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@@ -87,35 +101,133 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const filesUrl = (path: string) =>
|
||||||
|
'/files/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const parseErrorMessage = async (res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||||
|
} catch {
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const rename = (doc: Doc, newName: string) => {
|
const exitEditing = () => {
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(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: `${doc.loc}/${oldName}`,
|
|
||||||
to: newName
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
|
try {
|
||||||
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Rename failed', err)
|
||||||
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
console.log("New folder")
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
@@ -124,6 +236,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -134,6 +247,19 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor} .name a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -145,18 +271,46 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
up(ev: KeyboardEvent) {
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
markKeyboardFollow()
|
||||||
left(ev: KeyboardEvent) { router.back() },
|
this.cursorMove(-1, ev)
|
||||||
|
},
|
||||||
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
|
this.cursorMove(1, ev)
|
||||||
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
|
left(ev: KeyboardEvent) {
|
||||||
|
// Only go back if we're in a subfolder (not at root)
|
||||||
|
if (props.path.length > 0) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor} a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
},
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -165,98 +319,120 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index = getCursorIndex()
|
||||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p].key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr)
|
|
||||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
if (moveto === N) focusBreadcrumb()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
'.headermain input[type="search"]'
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
watchEffect(() => {
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
// stale props - their watchers must not react to global store changes.
|
||||||
if (store.cursor) {
|
let isActive = true
|
||||||
const a = document.querySelector(
|
watch(
|
||||||
`#file-${store.cursor} .name a`
|
() => store.cursor,
|
||||||
) as HTMLAnchorElement | null
|
cursor => {
|
||||||
if (a) a.focus()
|
if (!isActive) return
|
||||||
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
|
exitEditing()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (!props.documents.length && store.cursor) {
|
() => store.cursor,
|
||||||
store.cursor = ''
|
cursor => {
|
||||||
focusBreadcrumb()
|
if (!isActive) return
|
||||||
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(
|
||||||
|
`#file-${cursor} .name a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
|
store.cursor = ''
|
||||||
|
focusBreadcrumb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
let nowkey = ref(0)
|
let nowkey = ref(0)
|
||||||
let modifiedTimer: any = null
|
let modifiedTimer: any = null
|
||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
nowkey.value = Math.floor(Date.now() / 1000)
|
nowkey.value = Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
updateModified()
|
||||||
|
modifiedTimer = setInterval(updateModified, 1000)
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
onActivated(() => {
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
isActive = true
|
||||||
const control = connect(controlUrl, {
|
})
|
||||||
open() {
|
onDeactivated(() => {
|
||||||
control.send(
|
isActive = false
|
||||||
JSON.stringify({
|
if (editing.value) exitEditing()
|
||||||
op: 'mkdir',
|
})
|
||||||
path: `${doc.loc}/${name}`
|
onUnmounted(() => {
|
||||||
})
|
keyboardFollowScroll.cancel()
|
||||||
)
|
clearInterval(modifiedTimer)
|
||||||
},
|
})
|
||||||
message(ev: MessageEvent) {
|
const editRoute = (path: string) =>
|
||||||
const msg = JSON.parse(ev.data)
|
'/' +
|
||||||
if ('error' in msg) {
|
path
|
||||||
console.error('Mkdir failed', msg.error.message, msg.error)
|
.split('/')
|
||||||
editing.value = null
|
.map(part => encodeURIComponent(part))
|
||||||
} else {
|
.join('/')
|
||||||
console.log('mkdir', msg)
|
|
||||||
router.push(doc.urlrouter)
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// We should get an update from watch but this is quicker
|
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
|
store.addGhost(doc)
|
||||||
|
store.cursor = doc.key
|
||||||
|
exitEditing()
|
||||||
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
|
try {
|
||||||
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
if (doc.dir) {
|
||||||
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create failed', err)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
const docloc = docs[i].loc
|
const docloc = docs[i]!.loc
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||||
}
|
}
|
||||||
const selectionIndeterminate = computed({
|
const selectionIndeterminate = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
@@ -290,20 +466,108 @@ const allSelected = computed({
|
|||||||
|
|
||||||
const loc = computed(() => props.path.join('/'))
|
const loc = computed(() => props.path.join('/'))
|
||||||
|
|
||||||
|
const downloadFile = (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
if (doc.dir) {
|
||||||
|
// Download folder as ZIP
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/zip/${doc.key}/${doc.name}.zip`
|
||||||
|
a.download = ''
|
||||||
|
a.click()
|
||||||
|
store.showToast(`Downloading ${doc.name}.zip`)
|
||||||
|
} else {
|
||||||
|
// Download single file
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/files/${path}`
|
||||||
|
a.download = ''
|
||||||
|
a.click()
|
||||||
|
store.showToast(`Downloading ${doc.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLink = async (doc: Doc) => {
|
||||||
|
const url = new URL(doc.url, window.location.origin).href
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
store.showToast('📋 Link copied!')
|
||||||
|
} catch {
|
||||||
|
store.showToast('Failed to copy link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyImage = async (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
try {
|
||||||
|
store.showToast('Copying image...')
|
||||||
|
const res = await fetch(`/files/${path}`)
|
||||||
|
const blob = await res.blob()
|
||||||
|
// Convert to PNG if needed (clipboard only supports PNG)
|
||||||
|
if (blob.type !== 'image/png') {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = URL.createObjectURL(blob)
|
||||||
|
await new Promise(r => (img.onload = r))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||||
|
const pngBlob = await new Promise<Blob>(r =>
|
||||||
|
canvas.toBlob(b => r(b!), 'image/png')
|
||||||
|
)
|
||||||
|
URL.revokeObjectURL(img.src)
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
}
|
||||||
|
store.showToast('📋 Image copied!')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Copy image failed', e)
|
||||||
|
store.showToast('Failed to copy image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = async (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
store.hideDoc(path)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed', err)
|
||||||
|
store.unhideDoc(path)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
ContextMenu.showContextMenu({
|
const items = [
|
||||||
x: ev.x, y: ev.y, items: [
|
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
|
||||||
],
|
]
|
||||||
})
|
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: '✏️ Rename',
|
||||||
|
onClick: () => {
|
||||||
|
editing.value = doc
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||||
|
)
|
||||||
|
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.file-explorer {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -364,6 +628,12 @@ table td {
|
|||||||
.name .rename-button {
|
.name .rename-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
tbody tr:hover .name .rename-button {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
animation: appear calc(5 * var(--transition-time)) linear;
|
animation: appear calc(5 * var(--transition-time)) linear;
|
||||||
}
|
}
|
||||||
@keyframes appear {
|
@keyframes appear {
|
||||||
@@ -392,9 +662,6 @@ tbody tr.cursor {
|
|||||||
.sortcolumn:hover {
|
.sortcolumn:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.sortcolumn:hover::after {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.sortcolumn {
|
.sortcolumn {
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -437,12 +704,6 @@ tbody .selection input {
|
|||||||
content: '📁';
|
content: '📁';
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.empty-container {
|
|
||||||
padding-top: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.folder-change {
|
.folder-change {
|
||||||
margin-left: -.5rem;
|
margin-left: -.5rem;
|
||||||
}
|
}
|
||||||
@@ -453,4 +714,3 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="modified right">
|
<td class="modified right">
|
||||||
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time>
|
<time
|
||||||
|
:datetime=datetime
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
|
>{{ modified }}</time>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { formatUnixDate } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
now: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
// Reference props.now to trigger reactivity when time updates
|
||||||
|
const modified = computed(() => {
|
||||||
|
props.now // trigger reactivity
|
||||||
|
return formatUnixDate(props.doc.mtime)
|
||||||
|
})
|
||||||
|
|
||||||
const datetime = computed(() =>
|
const datetime = computed(() =>
|
||||||
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
||||||
)
|
)
|
||||||
|
|
||||||
const tooltip = computed(() =>
|
const tooltipText = computed(() =>
|
||||||
datetime.value.replace('T', '\n').replace('Z', ' UTC')
|
datetime.value.replace('T', ' ').replace('Z', ' UTC')
|
||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
doc: Doc
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const input = ref<HTMLInputElement | null>(null)
|
const input = ref<HTMLInputElement | null>(null)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
@@ -60,6 +60,7 @@ input#FileRenameInput {
|
|||||||
padding: .75em;
|
padding: .75em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td>
|
<td
|
||||||
|
class="size right"
|
||||||
|
:class="sizeClass"
|
||||||
|
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
|
||||||
|
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
|
||||||
|
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
|
||||||
|
>
|
||||||
|
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
|
||||||
|
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { formatSize } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
const sizeClass = computed(() => {
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
return +unit ? "bytes" : unit
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const sizeClass = computed(() => {
|
||||||
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
|
return +unit ? 'bytes' : unit
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipText = computed(() => {
|
||||||
|
const { size, allocated } = props.doc
|
||||||
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.before-size {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
.size.empty { color: #555 }
|
.size.empty { color: #555 }
|
||||||
.size.bytes { color: #77a }
|
.size.bytes { color: #77a }
|
||||||
.size.kB { color: #474 }
|
.size.kB { color: #474 }
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<template v-if=showFolderBreadcrumb(index)>
|
<GalleryFigure
|
||||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
:doc=doc
|
||||||
<div class="spacer"></div>
|
:editing="editing === doc ? {rename, exit} : null"
|
||||||
</template>
|
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||||
</GalleryFigure>
|
@menu="contextMenu($event, doc)"
|
||||||
|
@rename="onFigureRename(doc)"
|
||||||
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch
|
||||||
|
} from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@@ -27,39 +42,244 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const filesUrl = (path: string) =>
|
||||||
|
'/files/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const parseErrorMessage = async (res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||||
|
} catch {
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const exit = () => { editing.value = null }
|
const exit = () => {
|
||||||
const rename = (doc: Doc, newName: string) => {
|
editing.value = null
|
||||||
|
}
|
||||||
|
const onFigureRename = (doc: Doc) => {
|
||||||
|
editing.value = doc
|
||||||
|
store.cursor = doc.key
|
||||||
|
}
|
||||||
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(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: `${doc.loc}/${oldName}`,
|
|
||||||
to: newName
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
|
try {
|
||||||
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Rename failed', err)
|
||||||
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const gallery = ref<HTMLElement>()
|
const gallery = ref<HTMLElement>()
|
||||||
const columns = computed(() => {
|
const columnCount = ref(1)
|
||||||
if (!gallery.value) return 1
|
const columnWidthPx = ref(240)
|
||||||
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
const emPx = ref(16)
|
||||||
|
const aspectByKey = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
const optimalRowHeightPx = (ratios: number[]) => {
|
||||||
|
const w = Math.max(1, columnWidthPx.value)
|
||||||
|
const minH = Math.max(1, Math.round(7 * emPx.value))
|
||||||
|
const maxH = Math.max(minH, Math.round(30 * emPx.value))
|
||||||
|
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
||||||
|
if (usable.length === 0) return Math.round(15 * emPx.value)
|
||||||
|
|
||||||
|
let bestH = Math.round(15 * emPx.value)
|
||||||
|
let bestScore = -1
|
||||||
|
for (let h = minH; h <= maxH; h++) {
|
||||||
|
let score = 0
|
||||||
|
for (const ar of usable) {
|
||||||
|
let shownW = w
|
||||||
|
let shownH = w * ar
|
||||||
|
if (shownH > h) {
|
||||||
|
shownH = h
|
||||||
|
shownW = h / ar
|
||||||
|
}
|
||||||
|
// Fill efficiency in the row cell (0..1)
|
||||||
|
score += (shownW * shownH) / (w * h)
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestH = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestH
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAspect = (key: string, ar: number) => {
|
||||||
|
if (!Number.isFinite(ar) || ar <= 0) return
|
||||||
|
if (aspectByKey.value[key] === ar) return
|
||||||
|
aspectByKey.value = {
|
||||||
|
...aspectByKey.value,
|
||||||
|
[key]: ar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowHeightsByKey = computed<Record<string, string>>(() => {
|
||||||
|
const docs = props.documents
|
||||||
|
const cols = Math.max(1, columnCount.value)
|
||||||
|
const byKey = aspectByKey.value
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
|
||||||
|
const assignRows = (group: Doc[]) => {
|
||||||
|
for (let start = 0; start < group.length; start += cols) {
|
||||||
|
const row = group.slice(start, start + cols)
|
||||||
|
const ratios = row
|
||||||
|
.filter(doc => doc.previewable)
|
||||||
|
.map(doc => byKey[doc.key])
|
||||||
|
.filter((ar): ar is number => ar != null)
|
||||||
|
const height = `${optimalRowHeightPx(ratios)}px`
|
||||||
|
for (const doc of row) out[doc.key] = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let group: Doc[] = []
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
|
||||||
|
assignRows(group)
|
||||||
|
group = []
|
||||||
|
}
|
||||||
|
group.push(docs[i]!)
|
||||||
|
}
|
||||||
|
assignRows(group)
|
||||||
|
|
||||||
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Seed collected ratios from server-provided ar values on docs
|
||||||
|
const seedFromDocs = () => {
|
||||||
|
for (const doc of props.documents)
|
||||||
|
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImgLoad = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
|
||||||
|
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
|
||||||
|
if (!anchor) return
|
||||||
|
const key = anchor.id.slice('file-'.length)
|
||||||
|
if (!key) return
|
||||||
|
setAspect(key, img.naturalHeight / img.naturalWidth)
|
||||||
|
}
|
||||||
|
const updateColumns = () => {
|
||||||
|
if (!gallery.value) return
|
||||||
|
if (gallery.value.getBoundingClientRect().width <= 0) return
|
||||||
|
const style = getComputedStyle(gallery.value)
|
||||||
|
const templates = style.gridTemplateColumns
|
||||||
|
.split(' ')
|
||||||
|
.filter(part => !!part && part !== 'none')
|
||||||
|
columnCount.value = Math.max(1, templates.length)
|
||||||
|
const first = templates[0]
|
||||||
|
if (first && first.endsWith('px')) {
|
||||||
|
const parsed = Number.parseFloat(first)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
|
||||||
|
}
|
||||||
|
const parsedEm = Number.parseFloat(style.fontSize)
|
||||||
|
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||||
|
}
|
||||||
|
const columns = computed(() => columnCount.value)
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
@@ -69,6 +289,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -83,6 +304,19 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor}`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -94,15 +328,42 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
|
up(ev: KeyboardEvent) {
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
|
markKeyboardFollow()
|
||||||
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
this.cursorMove(-columns.value, ev)
|
||||||
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
},
|
||||||
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
|
this.cursorMove(columns.value, ev)
|
||||||
|
},
|
||||||
|
left(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
|
this.cursorMove(-1, ev)
|
||||||
|
},
|
||||||
|
right(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
|
this.cursorMove(1, ev)
|
||||||
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -111,11 +372,10 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index = getCursorIndex()
|
||||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
|
||||||
// Stop navigation sideways away from the grid (only with up/down)
|
// Stop navigation sideways away from the grid (only with up/down)
|
||||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||||
// Calculate new position
|
// Calculate new position
|
||||||
let moveto
|
let moveto
|
||||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||||
@@ -124,98 +384,138 @@ defineExpose({
|
|||||||
// Wrapping either end, just land outside the list
|
// Wrapping either end, just land outside the list
|
||||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||||
}
|
}
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p].key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr)
|
|
||||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
if (moveto === N) focusBreadcrumb()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
'.headermain input[type="search"]'
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
watchEffect(() => {
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||||
if (editing.value) store.cursor = editing.value.key
|
// stale props - their watchers must not react to global store changes.
|
||||||
if (store.cursor) {
|
let isActive = true
|
||||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
watch(
|
||||||
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
|
exit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (!props.documents.length && store.cursor) {
|
() => store.cursor,
|
||||||
store.cursor = ''
|
cursor => {
|
||||||
focusBreadcrumb()
|
if (!isActive) return
|
||||||
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||||
|
if (a) {
|
||||||
|
a.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
|
store.cursor = ''
|
||||||
|
focusBreadcrumb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
const attachGalleryObservers = () => {
|
||||||
|
if (!gallery.value || resizeObserver) return
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachGalleryObservers = () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
|
updateColumns()
|
||||||
|
seedFromDocs()
|
||||||
|
attachGalleryObservers()
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
onActivated(() => {
|
||||||
const control = connect(controlUrl, {
|
isActive = true
|
||||||
open() {
|
nextTick(() => {
|
||||||
control.send(
|
updateColumns()
|
||||||
JSON.stringify({
|
attachGalleryObservers()
|
||||||
op: 'mkdir',
|
|
||||||
path: `${doc.loc}/${name}`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
message(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(doc.urlrouter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
// We should get an update from watch but this is quicker
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
isActive = false
|
||||||
|
detachGalleryObservers()
|
||||||
|
if (editing.value) exit()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
|
detachGalleryObservers()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||||
|
watch(() => props.documents, seedFromDocs)
|
||||||
|
const editRoute = (path: string) =>
|
||||||
|
'/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
|
store.addGhost(doc)
|
||||||
|
store.cursor = doc.key
|
||||||
|
exit()
|
||||||
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
|
try {
|
||||||
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
if (doc.dir) {
|
||||||
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create failed', err)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
const docloc = docs[i].loc
|
const docloc = docs[i]!.loc
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||||
}
|
}
|
||||||
const selectionIndeterminate = computed({
|
|
||||||
get: () => {
|
|
||||||
return (
|
|
||||||
props.documents.length > 0 &&
|
|
||||||
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
|
|
||||||
!allSelected.value
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
set: (value: boolean) => {}
|
|
||||||
})
|
|
||||||
const allSelected = computed({
|
const allSelected = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
@@ -237,13 +537,96 @@ const allSelected = computed({
|
|||||||
|
|
||||||
const loc = computed(() => props.path.join('/'))
|
const loc = computed(() => props.path.join('/'))
|
||||||
|
|
||||||
|
const downloadFile = (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
if (doc.dir) {
|
||||||
|
// Download folder as ZIP
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/zip/${doc.key}/${doc.name}.zip`
|
||||||
|
a.download = ''
|
||||||
|
a.click()
|
||||||
|
store.showToast(`Downloading ${doc.name}.zip`)
|
||||||
|
} else {
|
||||||
|
// Download single file
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/files/${path}`
|
||||||
|
a.download = ''
|
||||||
|
a.click()
|
||||||
|
store.showToast(`Downloading ${doc.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLink = async (doc: Doc) => {
|
||||||
|
const url = new URL(doc.url, window.location.origin).href
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
store.showToast('📋 Link copied!')
|
||||||
|
} catch {
|
||||||
|
store.showToast('Failed to copy link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyImage = async (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
try {
|
||||||
|
store.showToast('Copying image...')
|
||||||
|
const res = await fetch(`/files/${path}`)
|
||||||
|
const blob = await res.blob()
|
||||||
|
// Convert to PNG if needed (clipboard only supports PNG)
|
||||||
|
if (blob.type !== 'image/png') {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = URL.createObjectURL(blob)
|
||||||
|
await new Promise(r => (img.onload = r))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||||
|
const pngBlob = await new Promise<Blob>(r =>
|
||||||
|
canvas.toBlob(b => r(b!), 'image/png')
|
||||||
|
)
|
||||||
|
URL.revokeObjectURL(img.src)
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
}
|
||||||
|
store.showToast('📋 Image copied!')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Copy image failed', e)
|
||||||
|
store.showToast('Failed to copy image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = async (doc: Doc) => {
|
||||||
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
store.hideDoc(path)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed', err)
|
||||||
|
store.unhideDoc(path)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
ContextMenu.showContextMenu({
|
const items = [
|
||||||
x: ev.x, y: ev.y, items: [
|
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
|
||||||
],
|
]
|
||||||
})
|
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: '✏️ Rename',
|
||||||
|
onClick: () => {
|
||||||
|
editing.value = doc
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||||
|
)
|
||||||
|
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -254,13 +637,13 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
grid-template-rows: repeat(minmax(auto, 15em));
|
align-items: start;
|
||||||
align-items: end;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.breadcrumb {
|
.folder-indicator {
|
||||||
border-radius: .5em 0 0 .5em;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.spacer {
|
.folder-start {
|
||||||
flex: 0 1000000000 4rem;
|
grid-column-start: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,55 +1,195 @@
|
|||||||
<template>
|
<template>
|
||||||
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
|
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
|
||||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
|
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click=onclick
|
@click=onclick
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
<MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<div class="titlespacer"></div>
|
<div class="titlespacer"></div>
|
||||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
|
<div class="filename-row rename-row">
|
||||||
|
<div class="rename-wrap">
|
||||||
|
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
<div class="filename-row">
|
||||||
|
<span class="filename-group">
|
||||||
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="rename-btn" @click="emit('rename')" title="Rename">✏️</button>
|
||||||
|
</div>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
|
<div class="tooltip-name">{{ snap.name }}</div>
|
||||||
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
const router = useRouter()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (name: string) => void;
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void;
|
exit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc,
|
doc: Doc
|
||||||
editing?: EditingProp,
|
editing?: EditingProp
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'rename'): void
|
||||||
|
(e: 'menu', ev: MouseEvent): void
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const tooltipText = computed(() => props.doc.key)
|
||||||
|
|
||||||
|
const sparseText = computed(() => {
|
||||||
|
const { size, allocated } = props.doc
|
||||||
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Single subscription to docVersion; all doc-derived values come from here.
|
||||||
|
// This is needed because Doc instances are non-reactive plain objects, so
|
||||||
|
// mutating doc.name alone won't invalidate computed caches.
|
||||||
|
const snap = computed(() => {
|
||||||
|
void store.docVersion
|
||||||
|
const { name, ext } = props.doc
|
||||||
|
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
displayName: base.replace(/[_.]+/g, ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) {
|
||||||
|
ev.preventDefault()
|
||||||
|
} else if (props.doc.text) {
|
||||||
|
ev.preventDefault()
|
||||||
|
router.push(props.doc.editurl.replace('/#', ''))
|
||||||
|
}
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.tooltip-name {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tooltip-details {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tooltip-sparse {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.after-name {
|
||||||
|
margin-left: 0.3em;
|
||||||
|
}
|
||||||
|
.filename-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.filename-row::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
width: 1.4em;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.filename-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
cursor: default;
|
||||||
|
padding: .5em 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-ext {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
padding: 0 .15em 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.rename-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0.2em, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.filename-row:hover .rename-btn {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
|
max-height: var(--gallery-figure-height, 15em);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -57,12 +197,16 @@ figure {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: height 0.4s ease, max-height 0.4s ease;
|
||||||
}
|
}
|
||||||
figure > article {
|
figure > article {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
figure :deep(.video-container) {
|
||||||
|
height: var(--gallery-figure-height, 15em);
|
||||||
|
}
|
||||||
.titlespacer {
|
.titlespacer {
|
||||||
flex-shrink: 100000;
|
flex-shrink: 100000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -78,9 +222,9 @@ figcaption {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
figcaption input[type='checkbox'] {
|
figcaption input[type='checkbox'] {
|
||||||
width: 1.5em;
|
width: 1.1em;
|
||||||
height: 1.5em;
|
height: 1.1em;
|
||||||
margin: .25em 0 .25em .25em;
|
margin: .25em .4em .25em .35em;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: opacity var(--transition-time) ease-in-out;
|
transition: opacity var(--transition-time) ease-in-out;
|
||||||
@@ -88,17 +232,10 @@ figcaption input[type='checkbox'] {
|
|||||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
figcaption span {
|
.cursor .filename {
|
||||||
cursor: default;
|
color: var(--accent-color);
|
||||||
padding: .5em;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.cursor figcaption span {
|
.cursor .file-ext {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
figcaption .namespacer {
|
figcaption .namespacer {
|
||||||
@@ -106,4 +243,17 @@ figcaption .namespacer {
|
|||||||
height: 2em;
|
height: 2em;
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
|
.rename-wrap {
|
||||||
|
font-size: 0.8em;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.rename-row {
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.rename-wrap :deep(#FileRenameInput) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,93 +1,213 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain buttons">
|
<nav class="headermain buttons">
|
||||||
<template v-if="store.error">
|
<template v-if="!props.editorMode">
|
||||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
<UploadButton :path="props.path" />
|
||||||
<div class="smallgap"></div>
|
<SvgButton
|
||||||
</template>
|
name="create-file"
|
||||||
<UploadButton :path="props.path" />
|
tooltip="New file"
|
||||||
<SvgButton
|
@click="() => { store.fileExplorer!.newFile() }"
|
||||||
name="create-folder"
|
|
||||||
data-tooltip="New folder"
|
|
||||||
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
|
|
||||||
/>
|
|
||||||
<slot></slot>
|
|
||||||
<div class="spacer smallgap"></div>
|
|
||||||
<template v-if="showSearchInput">
|
|
||||||
<input
|
|
||||||
ref="search"
|
|
||||||
type="search"
|
|
||||||
:value="query"
|
|
||||||
@input="updateSearch"
|
|
||||||
placeholder="Find files"
|
|
||||||
class="margin-input"
|
|
||||||
/>
|
/>
|
||||||
|
<SvgButton
|
||||||
|
name="create-folder"
|
||||||
|
tooltip="New folder"
|
||||||
|
@click="() => { store.fileExplorer!.newFolder() }"
|
||||||
|
/>
|
||||||
|
<div class="smallgap"></div>
|
||||||
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
|
<div class="search-group">
|
||||||
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
|
<input
|
||||||
|
ref="search"
|
||||||
|
type="search"
|
||||||
|
:value="query"
|
||||||
|
@input="updateSearch"
|
||||||
|
@keydown.escape="clearSearch"
|
||||||
|
/>
|
||||||
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
|
<span class="sort-label">Order</span>
|
||||||
|
<span class="keycap">1</span>
|
||||||
|
<span class="keycap">2</span>
|
||||||
|
<span class="keycap">3</span>
|
||||||
|
</div>
|
||||||
|
<SvgButton
|
||||||
|
v-if="props.editorMode"
|
||||||
|
name="disk"
|
||||||
|
tooltip="Save (Ctrl/Cmd+S)"
|
||||||
|
@click="store.editorSave?.()"
|
||||||
|
/>
|
||||||
|
<div class="spacer smallgap"></div>
|
||||||
|
<DiskSpace v-if="store.space.disk" />
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
|
import router from '@/router'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { ref, nextTick, watchEffect } from 'vue'
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import router from '@/router';
|
import { showAuthIframe } from 'paskia'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import DiskSpace from './DiskSpace.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const showSearchInput = ref<boolean>(false)
|
const ssoStore = useSsoAuthStore()
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const searchButton = ref<HTMLButtonElement | null>()
|
const textInputFocused = ref(false)
|
||||||
const props = defineProps<{
|
|
||||||
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
|
editorMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const closeSearch = (ev: Event) => {
|
const isInputElement = (el: Element | null): boolean => {
|
||||||
if (!showSearchInput.value) return // Already closing
|
if (!el || !(el instanceof HTMLElement)) return false
|
||||||
showSearchInput.value = false
|
return el instanceof HTMLInputElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTextInputFocused = () => {
|
||||||
|
textInputFocused.value = isInputElement(document.activeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSortHints = computed(() => !textInputFocused.value)
|
||||||
|
|
||||||
|
const clearSearch = (ev: Event) => {
|
||||||
|
const input = search.value
|
||||||
|
if (input) {
|
||||||
|
input.value = ''
|
||||||
|
updateSearch(ev)
|
||||||
|
}
|
||||||
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||||||
breadcrumb.focus()
|
breadcrumb.focus()
|
||||||
updateSearch(ev)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusSearch = () => {
|
||||||
|
search.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pending route update
|
||||||
|
let pendingRouteUpdate: number | null = null
|
||||||
|
|
||||||
const updateSearch = (ev: Event) => {
|
const updateSearch = (ev: Event) => {
|
||||||
const q = (ev.target as HTMLInputElement).value
|
const q = (ev.target as HTMLInputElement).value
|
||||||
let p = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
p = p ? `/${p}` : ''
|
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
// Start search immediately via store (worker handles it async)
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
store.search(q, loc)
|
||||||
if (!props.query && q) router.push(u)
|
|
||||||
else router.replace(u)
|
// Cancel any pending route update
|
||||||
}
|
if (pendingRouteUpdate !== null) {
|
||||||
const toggleSearchInput = (ev: Event) => {
|
cancelAnimationFrame(pendingRouteUpdate)
|
||||||
showSearchInput.value = !showSearchInput.value
|
}
|
||||||
if (!showSearchInput.value) return closeSearch(ev)
|
|
||||||
nextTick(() => {
|
// Schedule route update - will be cancelled if user types again
|
||||||
const input = search.value
|
pendingRouteUpdate = requestAnimationFrame(() => {
|
||||||
if (input) input.focus()
|
pendingRouteUpdate = null
|
||||||
|
let p = loc
|
||||||
|
p = p ? `/${p}` : ''
|
||||||
|
const url = q ? `${p}//${q}` : p || '/'
|
||||||
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
// Use replace to avoid building up history for each keystroke
|
||||||
|
router.replace(u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
watchEffect(() => {
|
|
||||||
if (props.query) showSearchInput.value = true
|
const toggleSearchInput = () => {
|
||||||
})
|
search.value?.focus()
|
||||||
|
}
|
||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
|
|
||||||
if (store.user.isLoggedIn) {
|
// For external auth, show user name as link to /auth/
|
||||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
} else {
|
items.push({
|
||||||
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
label: '👤 ' + (store.user.username || 'User Account'),
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = '/auth/'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show password change for non-SSO users
|
||||||
|
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
|
items.push({
|
||||||
|
label: '🔑 Change Password',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'settings'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.user.isLoggedIn) {
|
||||||
|
items.push({
|
||||||
|
label: '🔑 API Tokens',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'tokens'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.user.privileged) {
|
||||||
|
items.push({
|
||||||
|
label: '⚙️ Admin Settings',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'usermgmt'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.user.isLoggedIn) {
|
||||||
|
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||||
|
} else if (store.server.public) {
|
||||||
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
|
items.push({
|
||||||
|
label: '🔐 Login',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
|
resumeWatching()
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Login cancelled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'ℹ️ About Cista...',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'about'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
|
x: e.target.getBoundingClientRect().right,
|
||||||
items,
|
// @ts-ignore
|
||||||
|
y: e.target.getBoundingClientRect().bottom,
|
||||||
|
items
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
toggleSearchInput,
|
toggleSearchInput,
|
||||||
closeSearch,
|
clearSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateTextInputFocused()
|
||||||
|
window.addEventListener('focusin', updateTextInputFocused)
|
||||||
|
window.addEventListener('focusout', updateTextInputFocused)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('focusin', updateTextInputFocused)
|
||||||
|
window.removeEventListener('focusout', updateTextInputFocused)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -98,13 +218,106 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-height: 3em;
|
||||||
}
|
}
|
||||||
input[type='search'] {
|
.search-group {
|
||||||
background: var(--input-background);
|
position: relative;
|
||||||
color: var(--input-color);
|
display: flex;
|
||||||
border: 0;
|
align-items: center;
|
||||||
border-radius: 0.1em;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 1.5em;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 2.5em;
|
||||||
|
max-width: 20em;
|
||||||
|
}
|
||||||
|
.search-group:hover,
|
||||||
|
.search-group:focus-within {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.search-group:focus-within {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||||
|
}
|
||||||
|
.search-group:hover :deep(button.action-button),
|
||||||
|
.search-group:focus-within :deep(button.action-button) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.search-group:hover :deep(button.action-button svg),
|
||||||
|
.search-group:focus-within :deep(button.action-button svg) {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
.search-group:focus-within .search-hint {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.search-group :deep(.action-button) {
|
||||||
|
width: 2.2em;
|
||||||
|
height: 2.2em;
|
||||||
|
min-width: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search-group input[type='search'] {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-color);
|
||||||
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
max-width: 15ch;
|
padding: 0.2em 0.5em 0.2em 0;
|
||||||
|
font-size: inherit;
|
||||||
|
flex: 1 1 3em;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.search-hint {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5em;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
background: #ccc;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.search-hint {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sort-hints {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sort-label {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.keycap {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
background: #ccc;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
.sort-hints {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<template v-if="store.selected.size">
|
|
||||||
<div class="smallgap"></div>
|
|
||||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
|
||||||
<DownloadButton />
|
|
||||||
<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="store.selected.clear()">❌</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
const props = defineProps({
|
|
||||||
path: Array<string>
|
|
||||||
})
|
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
|
||||||
const op = (op: string, dst?: string) => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
const msg = {
|
|
||||||
op,
|
|
||||||
sel: sel.keys.map(key => {
|
|
||||||
const doc = sel.docs[key]
|
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
if (dst !== undefined) msg.dst = dst
|
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const res = JSON.parse(ev.data)
|
|
||||||
if ('error' in res) {
|
|
||||||
console.error('Control socket error', msg, res.error)
|
|
||||||
store.error = res.error.message
|
|
||||||
return
|
|
||||||
} else if (res.status === 'ack') {
|
|
||||||
console.log('Control ack OK', res)
|
|
||||||
control.close()
|
|
||||||
store.selected.clear()
|
|
||||||
return
|
|
||||||
} else console.log('Unknown control response', msg, res)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.select-text {
|
|
||||||
color: var(--accent-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModalDialog name="login" title="Authentication required">
|
|
||||||
<form @submit.prevent="login">
|
|
||||||
<div class="login-container">
|
|
||||||
<label for="username">Username:</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
|
||||||
v-model="loginForm.username"
|
|
||||||
/>
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
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>
|
|
||||||
<div class="dialog-buttons">
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<input id="submit" type="submit" value="Login" class="button-login" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ModalDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { loginUser } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const loginForm = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
error: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
try {
|
|
||||||
loginForm.error = ''
|
|
||||||
confirmLoading.value = true
|
|
||||||
const msg = await loginUser(loginForm.username, loginForm.password)
|
|
||||||
store.login(msg.data.username, !!msg.data.privileged)
|
|
||||||
} catch (error) {
|
|
||||||
const httpError = error as ISimpleError
|
|
||||||
loginForm.error = httpError.message || '🛑 Unknown error'
|
|
||||||
} finally {
|
|
||||||
confirmLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-container {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
<span
|
||||||
|
v-show="activeImageLoading"
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, 'loading-pulse']"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
|
<img
|
||||||
|
v-if="showPreviewImage"
|
||||||
|
:src="previewSrc"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !previewImageLoading }"
|
||||||
|
@load="onPreviewImageLoad"
|
||||||
|
@error="onPreviewImageError"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="doc.url"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !nativeImageLoading }"
|
||||||
|
@load="onNativeImageLoad"
|
||||||
|
@error="onNativeImageError"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||||
|
<span
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
||||||
|
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
|
<video v-else ref=vid :src=doc.url preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
|
<div class="play-overlay"><PlayIcon /></div>
|
||||||
|
</div>
|
||||||
<div v-else-if=audio() class="audio icon">
|
<div v-else-if=audio() class="audio icon">
|
||||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,18 +43,71 @@
|
|||||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { Play as PlayIcon } from '@/assets/svg'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
const media = computed(() => aud.value || vid.value)
|
const media = computed(() => aud.value || vid.value)
|
||||||
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
quality: string
|
quality: string
|
||||||
}>()
|
}>()
|
||||||
|
const previewImageFailed = ref(false)
|
||||||
|
const nativeImageFailed = ref(false)
|
||||||
|
const previewImageLoading = ref(true)
|
||||||
|
const nativeImageLoading = ref(true)
|
||||||
|
const previewSrc = computed(() =>
|
||||||
|
props.doc.previewurl
|
||||||
|
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
const showPreviewImage = computed(
|
||||||
|
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
|
||||||
|
)
|
||||||
|
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
|
||||||
|
const activeImageLoading = computed(() =>
|
||||||
|
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
|
||||||
|
)
|
||||||
|
const previewLoadFailed = computed(
|
||||||
|
() => previewImageFailed.value || nativeImageFailed.value
|
||||||
|
)
|
||||||
|
const loadingPulseDelayMs = computed(() => {
|
||||||
|
let hash = 0
|
||||||
|
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||||
|
return hash % 1800
|
||||||
|
})
|
||||||
|
const loadingPulseStyle = computed(() => ({
|
||||||
|
animationDelay: `${-loadingPulseDelayMs.value}ms`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onPreviewImageLoad = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onPreviewImageError = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
previewImageFailed.value = true
|
||||||
|
}
|
||||||
|
const onNativeImageLoad = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onNativeImageError = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
nativeImageFailed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.doc.key,
|
||||||
|
() => {
|
||||||
|
previewImageFailed.value = false
|
||||||
|
nativeImageFailed.value = false
|
||||||
|
previewImageLoading.value = true
|
||||||
|
nativeImageLoading.value = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const onplay = () => {
|
const onplay = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -33,42 +119,56 @@ const onpaused = () => {
|
|||||||
media.value.controls = false
|
media.value.controls = false
|
||||||
media.value.removeAttribute('data-playing')
|
media.value.removeAttribute('data-playing')
|
||||||
}
|
}
|
||||||
|
const applyPoster = (el: HTMLVideoElement) => {
|
||||||
|
if (props.doc.complete) {
|
||||||
|
el.poster = previewSrc.value
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('poster')
|
||||||
|
}
|
||||||
|
}
|
||||||
let fscurrent: HTMLVideoElement | null = null
|
let fscurrent: HTMLVideoElement | null = null
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
media.value.load() // Restore poster
|
media.value.load() // Restore poster
|
||||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
const medias = Array.from(document.querySelectorAll('video, audio')) as (
|
||||||
|
| HTMLAudioElement
|
||||||
|
| HTMLVideoElement
|
||||||
|
)[]
|
||||||
if (medias.length === 0) return
|
if (medias.length === 0) return
|
||||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||||
for (const i in medias) {
|
for (const i in medias) {
|
||||||
if (medias[i] === (fscurrent || media.value)) {
|
if (medias[i] === (fscurrent || media.value)) {
|
||||||
el = medias[+i + 1] || medias[0]
|
el = medias[+i + 1] ?? medias[0] ?? null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
|
if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
|
||||||
// Fullscreen needs to use the current video element for the next video
|
// Fullscreen needs to use the current video element for the next video
|
||||||
// because we are not allowed to fullscreen the next one.
|
// because we are not allowed to fullscreen the next one.
|
||||||
// FIXME: Write our own player to avoid this problem...
|
// FIXME: Write our own player to avoid this problem...
|
||||||
const elem = media.value as HTMLVideoElement
|
const elem = media.value as HTMLVideoElement
|
||||||
const playing = el as HTMLVideoElement
|
const playing = el as HTMLVideoElement
|
||||||
if (elem === playing) {
|
if (elem === playing) {
|
||||||
playing.play() // Only one video, just replay
|
playing.play() // Only one video, just replay
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!fscurrent) {
|
if (!fscurrent) {
|
||||||
elem.addEventListener('fullscreenchange', ev => {
|
elem.addEventListener(
|
||||||
if (!fscurrent) return
|
'fullscreenchange',
|
||||||
// Restore the original video element and continue with the one that was playing
|
ev => {
|
||||||
fscurrent.currentTime = elem.currentTime
|
if (!fscurrent) return
|
||||||
fscurrent.click()
|
// Restore the original video element and continue with the one that was playing
|
||||||
if (!elem.paused) fscurrent.play()
|
fscurrent.currentTime = elem.currentTime
|
||||||
fscurrent = null
|
fscurrent.click()
|
||||||
elem.src = props.doc.url
|
if (!elem.paused) fscurrent.play()
|
||||||
elem.poster = poster.value
|
fscurrent = null
|
||||||
onpaused()
|
elem.src = props.doc.url
|
||||||
}, {once: true})
|
applyPoster(elem)
|
||||||
|
onpaused()
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fscurrent = playing
|
fscurrent = playing
|
||||||
elem.src = playing.src
|
elem.src = playing.src
|
||||||
@@ -84,7 +184,10 @@ defineExpose({
|
|||||||
if (!media.value) return false
|
if (!media.value) return false
|
||||||
if (media.value.paused) {
|
if (media.value.paused) {
|
||||||
media.value.play()
|
media.value.play()
|
||||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
for (const el of Array.from(document.querySelectorAll('video, audio')) as (
|
||||||
|
| HTMLAudioElement
|
||||||
|
| HTMLVideoElement
|
||||||
|
)[]) {
|
||||||
if (el === media.value) continue
|
if (el === media.value) continue
|
||||||
el.pause()
|
el.pause()
|
||||||
}
|
}
|
||||||
@@ -93,18 +196,25 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
media,
|
media
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const video = () => props.doc.video
|
||||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
const audio = () => props.doc.audio
|
||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
const archive = () => props.doc.archive
|
||||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
const docs = () => props.doc.document
|
||||||
const preview = () => (
|
// image = requires server-side preview (browsers cannot display it natively)
|
||||||
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
|
// img = browser-viewable image that can be used directly in an <img> tag
|
||||||
props.doc.size > 500000 &&
|
const image = () => props.doc.image
|
||||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
const print = () => props.doc.print
|
||||||
)
|
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||||
|
const preview = () => {
|
||||||
|
const store = useMainStore()
|
||||||
|
return (
|
||||||
|
!(store.server.office_previews === false && docs()) &&
|
||||||
|
(image() || print() || (props.doc.img && props.doc.size > 500000))
|
||||||
|
)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -116,6 +226,59 @@ img, embed, .icon, audio, video {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
border-radius: calc(.5em / 8);
|
border-radius: calc(.5em / 8);
|
||||||
}
|
}
|
||||||
|
.preview-progress {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
.preview-progress .icon {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.preview-image-shell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.preview-image-shell img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.preview-image-shell img.ready {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.loading-pulse {
|
||||||
|
animation: media-preview-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes media-preview-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
}
|
||||||
.folder::before {
|
.folder::before {
|
||||||
content: '📁';
|
content: '📁';
|
||||||
}
|
}
|
||||||
@@ -159,10 +322,48 @@ img, embed, .icon, audio, video {
|
|||||||
figure.cursor .icon {
|
figure.cursor .icon {
|
||||||
filter: brightness(1);
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
img::before {
|
.video-container {
|
||||||
/* broken image */
|
position: relative;
|
||||||
text-shadow: 0 0 .5rem #000;
|
display: flex;
|
||||||
filter: grayscale(1);
|
align-items: center;
|
||||||
content: '❌';
|
justify-content: center;
|
||||||
|
min-width: 50%;
|
||||||
|
min-height: 6em;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.video-container.pending {
|
||||||
|
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
|
||||||
|
}
|
||||||
|
.video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(.5em / 8);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 4em;
|
||||||
|
height: 4em;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.play-overlay svg {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
fill: white;
|
||||||
|
margin-left: 0.25em; /* Visual centering for play triangle */
|
||||||
|
}
|
||||||
|
.video-container:hover .play-overlay {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
video[data-playing] + .play-overlay {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,90 +1,341 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
|
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
|
||||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
<div class="modal-dialog" :id="props.name" ref="dialog">
|
||||||
<div>
|
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||||
<slot>
|
<div class="modal-content">
|
||||||
Dialog with no content
|
<slot>
|
||||||
<button @click=close>OK</button>
|
Dialog with no content
|
||||||
</slot>
|
<button @click="close">OK</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watchEffect, nextTick } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const dialog = ref<HTMLDialogElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
let backdropHeld = false
|
||||||
|
|
||||||
const close = () => {
|
const ensureGlobalBackdropStyles = () => {
|
||||||
dialog.value!.close()
|
if (typeof document === 'undefined') return
|
||||||
store.dialog = ''
|
if (document.getElementById('paskia-dialog')) return
|
||||||
}
|
const style = document.createElement('style')
|
||||||
|
style.id = 'paskia-dialog'
|
||||||
const props = defineProps<{
|
style.textContent = `body::before {
|
||||||
title: string,
|
|
||||||
name: typeof store.dialog,
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const show = () => {
|
|
||||||
store.dialog = props.name
|
|
||||||
setTimeout(() => {
|
|
||||||
dialog.value!.showModal()
|
|
||||||
nextTick(() => {
|
|
||||||
const input = dialog.value!.querySelector('input')
|
|
||||||
if (input) input.focus()
|
|
||||||
})
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
defineExpose({ show, close })
|
|
||||||
watchEffect(() => {
|
|
||||||
if (dialog.value) show()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Style for the background */
|
|
||||||
dialog::backdrop {
|
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1099;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(0) brightness(1);
|
||||||
|
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop::before {
|
||||||
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#paskia-iframe {
|
||||||
|
border: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0008;
|
z-index: 9999;
|
||||||
backdrop-filter: blur(0.4em);
|
color-scheme: auto;
|
||||||
z-index: 1000;
|
background: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.insertBefore(style, document.head.firstChild)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the dialog by default */
|
const close = () => {
|
||||||
dialog[open] {
|
store.dialog = ''
|
||||||
background: #ddd;
|
}
|
||||||
color: black;
|
|
||||||
display: block;
|
const props = defineProps<{
|
||||||
border: none;
|
title: string
|
||||||
font-size: 1.2rem;
|
name: typeof store.dialog
|
||||||
border-radius: 0.5rem;
|
}>()
|
||||||
box-shadow: 0.2rem 0.2rem 1rem #000;
|
|
||||||
padding: 1rem;
|
const show = () => {
|
||||||
|
store.dialog = props.name
|
||||||
|
nextTick(() => {
|
||||||
|
overlay.value?.focus()
|
||||||
|
const input = dialog.value?.querySelector('input')
|
||||||
|
if (input) input.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defineExpose({ show, close })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.dialog === props.name,
|
||||||
|
isOpen => {
|
||||||
|
if (isOpen && !backdropHeld) {
|
||||||
|
ensureGlobalBackdropStyles()
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
backdropHeld = true
|
||||||
|
} else if (!isOpen && backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (overlay.value) {
|
||||||
|
overlay.value.focus()
|
||||||
|
const input = dialog.value?.querySelector('input')
|
||||||
|
if (input) input.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ===========================================
|
||||||
|
MODAL DIALOG GLOBAL STYLES
|
||||||
|
Shared styling for all modal dialogs.
|
||||||
|
Login page (auth.py) has matching CSS.
|
||||||
|
=========================================== */
|
||||||
|
|
||||||
|
/* Overlay - covers entire viewport */
|
||||||
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
z-index: 1100;
|
||||||
z-index: 1001;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* No backdrop - paskia handles that */
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
font: inherit;
|
/* Dialog container */
|
||||||
|
.modal-dialog {
|
||||||
|
background: #ddd;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0 1rem #0008;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
dialog[open] > h1 {
|
|
||||||
background: var(--soft-color);
|
/* Dialog title bar */
|
||||||
|
.modal-dialog > h1 {
|
||||||
|
background: #146;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
font-weight: normal;
|
||||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog[open] > div {
|
/* Dialog content area */
|
||||||
padding: 1em 0;
|
.modal-dialog > .modal-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headings inside dialog */
|
||||||
|
.modal-dialog h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.modal-dialog h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.modal-dialog a {
|
||||||
|
color: #146;
|
||||||
|
}
|
||||||
|
.modal-dialog a:hover {
|
||||||
|
color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.modal-dialog input[type="text"],
|
||||||
|
.modal-dialog input[type="password"],
|
||||||
|
.modal-dialog select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog input[type="text"]:focus,
|
||||||
|
.modal-dialog input[type="password"]:focus,
|
||||||
|
.modal-dialog select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.modal-dialog label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.modal-dialog button,
|
||||||
|
.modal-dialog input[type="submit"],
|
||||||
|
.modal-dialog input[type="reset"],
|
||||||
|
.modal-dialog .button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:hover,
|
||||||
|
.modal-dialog input[type="submit"]:hover,
|
||||||
|
.modal-dialog input[type="reset"]:hover,
|
||||||
|
.modal-dialog .button:hover {
|
||||||
|
background: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:disabled,
|
||||||
|
.modal-dialog input[type="submit"]:disabled,
|
||||||
|
.modal-dialog input[type="reset"]:disabled,
|
||||||
|
.modal-dialog .button:disabled {
|
||||||
|
background: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small button variant */
|
||||||
|
.modal-dialog .button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger button variant */
|
||||||
|
.modal-dialog .button.danger {
|
||||||
|
background: #c00;
|
||||||
|
}
|
||||||
|
.modal-dialog .button.danger:hover:not(:disabled) {
|
||||||
|
background: #f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form row layout (label + input side by side) */
|
||||||
|
.modal-dialog .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form grid for multiple label+input pairs */
|
||||||
|
.modal-dialog .form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog button row (footer) */
|
||||||
|
.modal-dialog .dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error text */
|
||||||
|
.modal-dialog .error-text {
|
||||||
|
color: #c00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.modal-dialog .success-message {
|
||||||
|
background: #f80;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data tables inside dialogs */
|
||||||
|
.modal-dialog table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th,
|
||||||
|
.modal-dialog td {
|
||||||
|
border: 1px solid #888;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th {
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog td {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox alignment in tables */
|
||||||
|
.modal-dialog td input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraph text */
|
||||||
|
.modal-dialog p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.modal-dialog .loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,13 +10,12 @@
|
|||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<div class="selection-bar" v-if="store.selected.size">
|
||||||
|
<div class="select-info">
|
||||||
|
<template v-if="selectionDisplay.folders.length <= 5">
|
||||||
|
<span class="select-folders">
|
||||||
|
<template v-for="(folder, i) in selectionDisplay.folders" :key="folder.path">
|
||||||
|
<span v-if="i > 0" class="folder-sep">, </span>
|
||||||
|
<a :href="'/#/' + folder.path" class="folder-link" @click.prevent="navigateTo(folder.path)">{{ folder.name }}</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="select-count">{{ store.selected.size }} items from {{ selectionDisplay.numFolders }} folders</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
|
<DownloadButton />
|
||||||
|
<SvgButton
|
||||||
|
name="link"
|
||||||
|
tooltip="Copy share link (Alt-click for read/write)"
|
||||||
|
@click="copyShareLink"
|
||||||
|
/>
|
||||||
|
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
|
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||||
|
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
|
<button
|
||||||
|
class="action-button unselect"
|
||||||
|
@click="store.selected.clear()"
|
||||||
|
@mouseenter="unselectTooltip?.startHover"
|
||||||
|
@mousemove="unselectTooltip?.updatePosition"
|
||||||
|
@mouseleave="unselectTooltip?.endHover"
|
||||||
|
>✖ deselect</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { createShareToken } from '@/repositories/User'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const props = defineProps({
|
||||||
|
path: Array<string>
|
||||||
|
})
|
||||||
|
|
||||||
|
const dst = computed(() => props.path!.join('/'))
|
||||||
|
|
||||||
|
const navigateTo = (path: string) => {
|
||||||
|
router.push('/' + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesUrl = (path: string) =>
|
||||||
|
'/files/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const parseErrorMessage = async (res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||||
|
} catch {
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate long names to reasonable length
|
||||||
|
const truncateName = (name: string, maxLen = 20): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderInfo {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionDisplay {
|
||||||
|
folders: FolderInfo[]
|
||||||
|
numFolders: number
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const totalSize = sel.keys.reduce((sum, key) => {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
return sum + (doc ? doc.size : 0)
|
||||||
|
}, 0)
|
||||||
|
const sizeStr = formatSize(totalSize)
|
||||||
|
|
||||||
|
// Group by folder location, storing file names
|
||||||
|
const folderGroups = new Map<string, string[]>()
|
||||||
|
for (const key of sel.keys) {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
if (!doc) continue
|
||||||
|
const loc = doc.loc || ''
|
||||||
|
if (!folderGroups.has(loc)) folderGroups.set(loc, [])
|
||||||
|
folderGroups.get(loc)!.push(doc.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numFolders = folderGroups.size
|
||||||
|
|
||||||
|
const folders = Array.from(folderGroups.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([loc, names]) => {
|
||||||
|
const count = names.length
|
||||||
|
// For single file, display truncated filename; for multiple, display folder name with count
|
||||||
|
let displayName: string
|
||||||
|
if (count === 1) {
|
||||||
|
displayName = truncateName(names[0]!)
|
||||||
|
} else {
|
||||||
|
const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
|
||||||
|
displayName = `${truncateName(folderName)} (${count})`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: displayName,
|
||||||
|
path: loc,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
numFolders,
|
||||||
|
size: sizeStr
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = async (opName: string, dst?: string) => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
const keys = sel.keys
|
||||||
|
const paths = sel.keys.map(key => {
|
||||||
|
const doc = sel.docs[key]!
|
||||||
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hide items being deleted or moved (optimistic update)
|
||||||
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.hideDoc(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (opName === 'rm') {
|
||||||
|
for (const path of paths) {
|
||||||
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
}
|
||||||
|
} else if (opName === 'mv' || opName === 'cp') {
|
||||||
|
if (keys.length === 0) throw new Error('No selected files')
|
||||||
|
const dstUrl = dst ? filesUrl(dst) : '/files/'
|
||||||
|
const query = `${opName}=${keys.join('+')}`
|
||||||
|
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
|
||||||
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported operation: ${opName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.selected.clear()
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error('REST file operation failed', opName, err)
|
||||||
|
store.error = message
|
||||||
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.unhideDoc(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyShareLink = async (ev: MouseEvent) => {
|
||||||
|
const mode: 'ro' | 'rw' = ev.altKey ? 'rw' : 'ro'
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
const paths = sel.keys
|
||||||
|
.map(key => {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
if (!doc) return ''
|
||||||
|
if (doc.loc === '/' || !doc.loc) return doc.name
|
||||||
|
return `${doc.loc}/${doc.name}`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!paths.length) {
|
||||||
|
store.showToast('No selected files')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await createShareToken(paths, mode)
|
||||||
|
await navigator.clipboard.writeText(token.url)
|
||||||
|
store.showToast(
|
||||||
|
mode === 'rw' ? 'Copied read/write share link' : 'Copied share link'
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to create share link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-color);
|
||||||
|
font-size: var(--header-font-size);
|
||||||
|
gap: 0.3em;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.select-info {
|
||||||
|
color: var(--accent-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.select-count {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.select-folders {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.folder-link,
|
||||||
|
.folder-link:link,
|
||||||
|
.folder-link:visited,
|
||||||
|
.folder-link:active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.folder-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.folder-sep {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.select-size {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<form>
|
<form>
|
||||||
<template v-if="store.user.isLoggedIn">
|
<template v-if="store.user.isLoggedIn">
|
||||||
<h3>Update your authentication</h3>
|
<h3>Update your authentication</h3>
|
||||||
<div class="login-container">
|
<div class="form-grid">
|
||||||
<label for="username">New password:</label>
|
<label for="passwordChange">New password:</label>
|
||||||
<input
|
<input
|
||||||
ref="passwordChange"
|
ref="passwordChange"
|
||||||
id="passwordChange"
|
id="passwordChange"
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="error-text">
|
|
||||||
{{ form.error || '\u00A0' }}
|
|
||||||
</h3>
|
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
@@ -47,35 +44,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { changePassword } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { changePassword } from '@/repositories/User'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const passwordChange = ref()
|
const passwordChange = ref()
|
||||||
const password = ref()
|
const password = ref()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
passwordChange: '',
|
passwordChange: '',
|
||||||
password: '',
|
password: ''
|
||||||
error: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
form.passwordChange = ''
|
form.passwordChange = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.error = ''
|
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
}
|
}
|
||||||
const submit = async (ev: Event) => {
|
const submit = async (ev: Event) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
try {
|
try {
|
||||||
form.error = ''
|
|
||||||
if (form.passwordChange) {
|
if (form.passwordChange) {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
form.error = '⚠️ Current password is required'
|
store.showToast('⚠️ Current password is required')
|
||||||
password.value!.focus()
|
password.value!.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
|
|||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
form.error = httpError.message || '🛑 Unknown error'
|
store.showToast(httpError.message || '🛑 Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -92,36 +87,5 @@ const submit = async (ev: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-container {
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sparse-indicator {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="action-button">
|
<button
|
||||||
<component :is="icon" />
|
class="action-button"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
|
>
|
||||||
|
<component :is="icons[name]" />
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { type IconName, icons } from '@/assets/svg'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: IconName
|
||||||
|
tooltip?: string
|
||||||
|
tabindex?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`))
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
const tooltipText = props.tooltip ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -22,18 +34,29 @@ const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`)
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
padding: 0.2em;
|
margin: 0 0.2em;
|
||||||
width: 3em;
|
padding: 0;
|
||||||
height: 3em;
|
width: 2.7em;
|
||||||
|
height: 2.7em;
|
||||||
|
min-width: 1.9em;
|
||||||
|
min-height: 1.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.action-button:hover,
|
.action-button:hover,
|
||||||
.action-button:focus {
|
.action-button:focus {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
svg {
|
.action-button svg {
|
||||||
fill: #ccc;
|
fill: #ccc;
|
||||||
transform: fill 0.2s ease;
|
transition: fill 0.2s ease;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
.action-button:hover svg,
|
.action-button:hover svg,
|
||||||
.action-button:focus svg {
|
.action-button:focus svg {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineEmits(['cancel'])
|
defineEmits(['cancel'])
|
||||||
|
|
||||||
@@ -38,30 +38,33 @@ const props = defineProps<{
|
|||||||
}
|
}
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
const percent = computed(() => (props.status.xfer / props.status.total) * 100)
|
||||||
const speed = computed(() => {
|
const speed = computed(() => {
|
||||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||||
return s // "Current speed"
|
return s // "Current speed"
|
||||||
})
|
})
|
||||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
const speeddisp = computed(() =>
|
||||||
|
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.transferprogress {
|
.transferprogress {
|
||||||
--bar: var(--accent-color);
|
--bar: var(--accent-color);
|
||||||
--nobar: var(--header-background);
|
--nobar: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.statustext {
|
.statustext {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin: 0 .5em;
|
margin: 0 .5em;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||||
</template>
|
</template>
|
||||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
|
||||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils';
|
import { collator, formatSize } from '@/utils'
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const fileInput = ref()
|
const fileInput = ref()
|
||||||
const folderInput = ref()
|
const folderInput = ref()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -25,6 +28,23 @@ type CloudFile = {
|
|||||||
cloudName: string
|
cloudName: string
|
||||||
cloudPos: number
|
cloudPos: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadRange = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type InflightBlock = {
|
||||||
|
name: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||||
|
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
|
||||||
function pasteHandler(event: ClipboardEvent) {
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
const infiles = [] as File[]
|
const infiles = [] as File[]
|
||||||
@@ -42,7 +62,9 @@ function pasteHandler(event: ClipboardEvent) {
|
|||||||
if (infiles.length || dirs.length) {
|
if (infiles.length || dirs.length) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
uploadFiles(infiles)
|
uploadFiles(infiles)
|
||||||
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
|
const base = props.path!.join('/')
|
||||||
|
for (const entry of dirs)
|
||||||
|
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||||
@@ -52,8 +74,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const cloudName = `${loc}/${entry.name}`
|
const cloudName = `${loc}/${entry.name}`
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const file = await new Promise(resolve => entry.file(resolve)) as File
|
const file = (await new Promise(resolve => entry.file(resolve))) as File
|
||||||
cloudfiles.push({file, cloudName, cloudPos: 0})
|
cloudfiles.push({ file, cloudName, cloudPos: 0 })
|
||||||
} else if (entry.isDirectory) {
|
} else if (entry.isDirectory) {
|
||||||
await pasteDirectory(entry, cloudName)
|
await pasteDirectory(entry, cloudName)
|
||||||
}
|
}
|
||||||
@@ -64,7 +86,9 @@ function uploadHandler(event: Event) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const input = event.target as HTMLInputElement | null
|
const input = event.target as HTMLInputElement | null
|
||||||
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
const infiles = Array.from(
|
||||||
|
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
|
||||||
|
) as File[]
|
||||||
if (input) input.value = ''
|
if (input) input.value = ''
|
||||||
if (infiles.length) uploadFiles(infiles)
|
if (infiles.length) uploadFiles(infiles)
|
||||||
}
|
}
|
||||||
@@ -72,24 +96,96 @@ function uploadHandler(event: Event) {
|
|||||||
const uploadFiles = (infiles: File[]) => {
|
const uploadFiles = (infiles: File[]) => {
|
||||||
const loc = props.path!.join('/')
|
const loc = props.path!.join('/')
|
||||||
let files = []
|
let files = []
|
||||||
|
let folderName = ''
|
||||||
for (const file of infiles) {
|
for (const file of infiles) {
|
||||||
|
const relPath = file.webkitRelativePath || file.name
|
||||||
|
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||||
files.push({
|
files.push({
|
||||||
file,
|
file,
|
||||||
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
|
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||||
cloudPos: 0,
|
cloudPos: 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
uploadCloudFiles(files)
|
uploadCloudFiles(files)
|
||||||
|
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
|
||||||
}
|
}
|
||||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||||
if (dotfiles.length) {
|
if (dotfiles.length) {
|
||||||
store.error = "Won't upload dotfiles"
|
store.showToast("Won't upload dotfiles")
|
||||||
console.log("Dotfiles omitted", dotfiles)
|
|
||||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||||
}
|
}
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||||
|
|
||||||
|
// Space check: reject the whole batch if there isn't enough free space.
|
||||||
|
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
|
||||||
|
const allDocs = getDocuments()
|
||||||
|
const docByPath = new Map<string, Doc>()
|
||||||
|
for (const d of allDocs) {
|
||||||
|
const path = d.loc ? `${d.loc}/${d.name}` : d.name
|
||||||
|
docByPath.set(path, d)
|
||||||
|
}
|
||||||
|
let overwriteSize = 0
|
||||||
|
for (const f of files) {
|
||||||
|
const existing = docByPath.get(f.cloudName)
|
||||||
|
if (existing && !existing.dir) overwriteSize += existing.size
|
||||||
|
}
|
||||||
|
const netNeed = batchTotal - overwriteSize
|
||||||
|
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
|
||||||
|
store.showToast(
|
||||||
|
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update: ghost folders and files
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const docs = getDocuments()
|
||||||
|
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
||||||
|
// Also check existing ghosts
|
||||||
|
for (const g of store.ghosts) {
|
||||||
|
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
|
||||||
|
}
|
||||||
|
const added = new Set<string>()
|
||||||
|
for (const f of files) {
|
||||||
|
const lastSlash = f.cloudName.lastIndexOf('/')
|
||||||
|
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
|
||||||
|
const name = f.cloudName.slice(lastSlash + 1)
|
||||||
|
// Ghost folders for intermediate directories
|
||||||
|
const parts = loc.split('/')
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
|
store.addGhost(
|
||||||
|
new Doc({
|
||||||
|
loc: parts.slice(0, i).join('/'),
|
||||||
|
name: parts[i],
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
|
mtime: now,
|
||||||
|
dir: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
added.add(folderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
|
const existing = byPath.get(f.cloudName)
|
||||||
|
if (!existing)
|
||||||
|
store.addGhost(
|
||||||
|
new Doc({
|
||||||
|
loc,
|
||||||
|
name,
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
size: f.file.size,
|
||||||
|
allocated: 0,
|
||||||
|
mtime: now,
|
||||||
|
dir: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
statsAdd(files)
|
statsAdd(files)
|
||||||
@@ -97,7 +193,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelUploads = () => {
|
const cancelUploads = () => {
|
||||||
|
uploadRunId += 1
|
||||||
upqueue = []
|
upqueue = []
|
||||||
|
blockQueue = []
|
||||||
|
inflightBlocks.clear()
|
||||||
|
uploadedBytes.clear()
|
||||||
|
store.uprogress.status = 'idle'
|
||||||
statReset()
|
statReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,30 +216,106 @@ const uprogress_init = {
|
|||||||
filename: '',
|
filename: '',
|
||||||
filesize: 0,
|
filesize: 0,
|
||||||
filepos: 0,
|
filepos: 0,
|
||||||
status: 'idle',
|
status: 'idle'
|
||||||
}
|
}
|
||||||
store.uprogress = {...uprogress_init}
|
store.uprogress = { ...uprogress_init }
|
||||||
|
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||||
|
const uploadedBytes = new Map<string, Set<number>>()
|
||||||
|
const inflightBlocks = new Map<string, InflightBlock>()
|
||||||
|
let smoothedBlockMs = 1500
|
||||||
|
let lastProgressTick = Date.now()
|
||||||
|
let lastVisualUploaded = 0
|
||||||
|
|
||||||
|
const inflightKey = (name: string, start: number) => `${name}:${start}`
|
||||||
|
|
||||||
|
const completedUploadedBytes = (name: string, size: number) => {
|
||||||
|
const uploaded = uploadedBytes.get(name)
|
||||||
|
if (!uploaded) return 0
|
||||||
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
|
let total = 0
|
||||||
|
for (let i = 0; i < size; i += blockSize) {
|
||||||
|
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulatedInflightBytes = (name: string, now: number) => {
|
||||||
|
let total = 0
|
||||||
|
for (const block of inflightBlocks.values()) {
|
||||||
|
if (block.name !== name) continue
|
||||||
|
const size = block.end - block.start
|
||||||
|
const elapsed = Math.max(0, now - block.startedAt)
|
||||||
|
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
|
||||||
|
total += size * fraction
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshProgress = (now: number) => {
|
||||||
|
const name = store.uprogress.filename
|
||||||
|
const size = store.uprogress.filesize
|
||||||
|
if (!name || !size) {
|
||||||
|
lastProgressTick = now
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = completedUploadedBytes(name, size)
|
||||||
|
const estimated = simulatedInflightBytes(name, now)
|
||||||
|
const visualUploaded = Math.min(size, Math.round(completed + estimated))
|
||||||
|
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
|
||||||
|
const dt = Math.max(1, now - lastProgressTick)
|
||||||
|
|
||||||
|
store.uprogress.filepos = visualUploaded
|
||||||
|
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
store.uprogress.statbytes += delta
|
||||||
|
store.uprogress.statdur += dt
|
||||||
|
store.uprogress.tlast = now
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisualUploaded = visualUploaded
|
||||||
|
lastProgressTick = now
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
const now = Date.now()
|
||||||
// Reset
|
const delta = refreshProgress(now)
|
||||||
|
if (delta > 0) return
|
||||||
|
if (now - store.uprogress.tlast > 3000) {
|
||||||
store.uprogress.statbytes = 0
|
store.uprogress.statbytes = 0
|
||||||
store.uprogress.statdur = 1
|
store.uprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
store.uprogress.statbytes *= 0.95
|
||||||
store.uprogress.statbytes *= .9
|
store.uprogress.statdur *= 0.95
|
||||||
store.uprogress.statdur *= .9
|
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
|
||||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
const statUpdate = ({ name, size, start, end }: UploadRange) => {
|
||||||
const now = Date.now()
|
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||||
store.uprogress.xfer = store.uprogress.filestart + end
|
|
||||||
store.uprogress.filepos = end
|
// Track which bytes have been uploaded (using start to end range)
|
||||||
store.uprogress.statbytes += end - start
|
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||||
store.uprogress.statdur += now - store.uprogress.tlast
|
const uploaded = uploadedBytes.get(name)!
|
||||||
store.uprogress.tlast = now
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
// File finished?
|
|
||||||
if (end === size) {
|
// Mark all bytes in this block as uploaded
|
||||||
|
for (let i = start; i < end; i += blockSize) {
|
||||||
|
uploaded.add(i)
|
||||||
|
}
|
||||||
|
refreshProgress(Date.now())
|
||||||
|
|
||||||
|
// Check if file is fully uploaded by examining the block queue
|
||||||
|
const currentUpload = blockQueue[0]
|
||||||
|
if (!currentUpload) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentUpload.file.cloudName === name &&
|
||||||
|
currentUpload.completed >= currentUpload.blocks.length
|
||||||
|
) {
|
||||||
|
// All blocks for this file have been uploaded
|
||||||
|
uploadedBytes.delete(name) // Clean up tracking
|
||||||
store.uprogress.filestart += size
|
store.uprogress.filestart += size
|
||||||
statNextFile()
|
statNextFile()
|
||||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||||
@@ -150,11 +327,15 @@ const statNextFile = () => {
|
|||||||
store.uprogress.filepos = 0
|
store.uprogress.filepos = 0
|
||||||
store.uprogress.filesize = f.file.size
|
store.uprogress.filesize = f.file.size
|
||||||
store.uprogress.filename = f.cloudName
|
store.uprogress.filename = f.cloudName
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = Date.now()
|
||||||
}
|
}
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
Object.assign(store.uprogress, uprogress_init)
|
Object.assign(store.uprogress, uprogress_init)
|
||||||
store.uprogress.t0 = Date.now()
|
store.uprogress.t0 = Date.now()
|
||||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = store.uprogress.t0
|
||||||
}
|
}
|
||||||
const statsAdd = (f: CloudFile[]) => {
|
const statsAdd = (f: CloudFile[]) => {
|
||||||
if (store.uprogress.files.length === 0) statReset()
|
if (store.uprogress.files.length === 0) statReset()
|
||||||
@@ -164,69 +345,204 @@ const statsAdd = (f: CloudFile[]) => {
|
|||||||
statNextFile()
|
statNextFile()
|
||||||
}
|
}
|
||||||
let upqueue = [] as CloudFile[]
|
let upqueue = [] as CloudFile[]
|
||||||
|
const MAX_PARALLEL_REQUESTS = 4
|
||||||
|
const RETRY_DELAY_MS = 400
|
||||||
|
|
||||||
// TODO: Rewrite as WebSocket class
|
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||||
const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
|
||||||
const ws = connect(uploadUrl, {
|
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||||
open(ev: Event) { resolve(ws) },
|
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||||
error(ev: Event) {
|
const FINAL_BLOCKS_COUNT = 2
|
||||||
console.error('Upload socket error', ev)
|
|
||||||
store.error = 'Upload socket error'
|
const fileSize = file.file.size
|
||||||
},
|
const blocks: { start: number; end: number }[] = []
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const res = JSON.parse(ev!.data)
|
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||||
if ('error' in res) {
|
// File is large enough, prioritize final blocks
|
||||||
console.error('Upload socket error', res.error)
|
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
|
||||||
store.error = res.error.message
|
|
||||||
return
|
// Add final blocks first
|
||||||
}
|
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||||
if (res.status === 'ack') {
|
const start = finalBlocksStart + i * BLOCK_SIZE
|
||||||
statUpdate(res.req)
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
} else console.log('Unknown upload response', res)
|
blocks.push({ start, end })
|
||||||
},
|
}
|
||||||
})
|
|
||||||
// @ts-ignore
|
// Add remaining blocks from beginning
|
||||||
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg))
|
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||||
// @ts-ignore
|
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||||
ws.sendData = async (data: any) => {
|
blocks.push({ start, end })
|
||||||
// Wait until the WS is ready to send another message
|
}
|
||||||
store.uprogress.status = "uploading"
|
} else {
|
||||||
await new Promise(resolve => {
|
// File is smaller, use sequential upload
|
||||||
const t = setInterval(() => {
|
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||||
if (ws.bufferedAmount > 1<<20) return
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
resolve(undefined)
|
blocks.push({ start, end })
|
||||||
clearInterval(t)
|
}
|
||||||
}, 1)
|
|
||||||
})
|
|
||||||
store.uprogress.status = "processing"
|
|
||||||
ws.send(data)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
const worker = async () => {
|
return blocks
|
||||||
const ws = await WSCreate()
|
|
||||||
while (upqueue.length) {
|
|
||||||
const f = upqueue[0]
|
|
||||||
const start = f.cloudPos
|
|
||||||
const end = Math.min(f.file.size, start + (1<<20))
|
|
||||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
|
||||||
const data = f.file.slice(start, end)
|
|
||||||
f.cloudPos = end
|
|
||||||
// Note: files may get modified during I/O
|
|
||||||
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
|
|
||||||
ws.sendMsg(control)
|
|
||||||
// @ts-ignore
|
|
||||||
await ws.sendData(data)
|
|
||||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
|
||||||
}
|
|
||||||
if (upqueue.length) startWorker()
|
|
||||||
store.uprogress.status = "idle"
|
|
||||||
workerRunning = false
|
|
||||||
}
|
}
|
||||||
let workerRunning: any = false
|
|
||||||
|
type BlockUpload = {
|
||||||
|
file: CloudFile
|
||||||
|
blocks: { start: number; end: number }[]
|
||||||
|
nextIndex: number
|
||||||
|
completed: number
|
||||||
|
runId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockQueue = [] as BlockUpload[]
|
||||||
|
let workerRunning = false
|
||||||
|
let uploadRunId = 0
|
||||||
|
|
||||||
|
const enqueuePendingUploads = () => {
|
||||||
|
while (upqueue.length) {
|
||||||
|
const file = upqueue.shift()!
|
||||||
|
const blocks = getUploadBlocks(file)
|
||||||
|
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadUrlForFile = (cloudName: string) => {
|
||||||
|
const normalized = cloudName.replace(/^\/+/, '')
|
||||||
|
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
|
||||||
|
return `/files/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadBlock = async (
|
||||||
|
upload: BlockUpload,
|
||||||
|
block: { start: number; end: number }
|
||||||
|
) => {
|
||||||
|
const body = upload.file.file.slice(block.start, block.end)
|
||||||
|
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
|
||||||
|
const fallbackReq = {
|
||||||
|
name: upload.file.cloudName,
|
||||||
|
size: upload.file.file.size,
|
||||||
|
start: block.start,
|
||||||
|
end: block.end
|
||||||
|
}
|
||||||
|
let attempt = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
attempt += 1
|
||||||
|
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
|
||||||
|
try {
|
||||||
|
const res = await fetch(uploadUrlForFile(upload.file.cloudName), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Range': range
|
||||||
|
},
|
||||||
|
body
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await res.text().catch(() => '')
|
||||||
|
const retryable = res.status >= 500 || res.status === 408 || res.status === 429
|
||||||
|
if (!retryable) throw new Error(message || `HTTP ${res.status}`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const payload = await res.json().catch(() => null)
|
||||||
|
return payload?.status === 'ack' && payload.req ? payload.req : fallbackReq
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
if (message === 'Upload cancelled') throw err
|
||||||
|
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
|
||||||
|
if (attempt % 10 === 0) {
|
||||||
|
console.warn(`Upload retry ${attempt} for ${upload.file.cloudName}: ${message}`)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||||
|
inflightBlocks.set(inflightKey(name, block.start), {
|
||||||
|
name,
|
||||||
|
start: block.start,
|
||||||
|
end: block.end,
|
||||||
|
startedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||||
|
const key = inflightKey(name, block.start)
|
||||||
|
const info = inflightBlocks.get(key)
|
||||||
|
if (!info) return
|
||||||
|
const elapsed = Math.max(1, Date.now() - info.startedAt)
|
||||||
|
smoothedBlockMs = smoothedBlockMs * 0.85 + elapsed * 0.15
|
||||||
|
inflightBlocks.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = async (runId: number) => {
|
||||||
|
try {
|
||||||
|
while (runId === uploadRunId) {
|
||||||
|
enqueuePendingUploads()
|
||||||
|
if (!blockQueue.length) break
|
||||||
|
|
||||||
|
const upload = blockQueue[0]!
|
||||||
|
const inflight = new Set<Promise<void>>()
|
||||||
|
|
||||||
|
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
|
||||||
|
while (
|
||||||
|
runId === uploadRunId &&
|
||||||
|
upload.nextIndex < upload.blocks.length &&
|
||||||
|
inflight.size < MAX_PARALLEL_REQUESTS
|
||||||
|
) {
|
||||||
|
const block = upload.blocks[upload.nextIndex++]!
|
||||||
|
store.uprogress.status = 'uploading'
|
||||||
|
startInflightBlock(upload.file.cloudName, block)
|
||||||
|
let task: Promise<void>
|
||||||
|
task = uploadBlock(upload, block)
|
||||||
|
.then(req => {
|
||||||
|
finishInflightBlock(upload.file.cloudName, block)
|
||||||
|
upload.completed += 1
|
||||||
|
statUpdate(req)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
finishInflightBlock(upload.file.cloudName, block)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(task)
|
||||||
|
})
|
||||||
|
inflight.add(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inflight.size) break
|
||||||
|
await Promise.race(inflight)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runId !== uploadRunId) return
|
||||||
|
|
||||||
|
if (upload.completed >= upload.blocks.length) {
|
||||||
|
blockQueue.shift()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (runId !== uploadRunId) return
|
||||||
|
console.error('Upload error', err)
|
||||||
|
store.error = err?.message || 'Upload failed'
|
||||||
|
uploadRunId += 1
|
||||||
|
upqueue = []
|
||||||
|
blockQueue = []
|
||||||
|
inflightBlocks.clear()
|
||||||
|
} finally {
|
||||||
|
store.uprogress.status = 'idle'
|
||||||
|
workerRunning = false
|
||||||
|
if (upqueue.length) startWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startWorker = () => {
|
const startWorker = () => {
|
||||||
if (workerRunning === false) workerRunning = setTimeout(() => {
|
if (workerRunning) return
|
||||||
workerRunning = true
|
workerRunning = true
|
||||||
worker()
|
const runId = uploadRunId
|
||||||
|
setTimeout(() => {
|
||||||
|
void worker(runId)
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=usermgmt title="Admin Settings">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<h3>Server Settings</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="serverName">Server name</label>
|
||||||
|
<div class="input-with-hint">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="serverName"
|
||||||
|
v-model="serverSettings.name"
|
||||||
|
@input="debouncedUpdateServerName"
|
||||||
|
:placeholder="store.server.name"
|
||||||
|
/>
|
||||||
|
<small>Leave empty to use the share folder name</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="publicAccess">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="publicAccess"
|
||||||
|
v-model="serverSettings.public"
|
||||||
|
@change="updateServerSettings"
|
||||||
|
/>
|
||||||
|
Public access (anyone can read and write)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template v-if="store.server.paskia">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h3>Users</h3>
|
||||||
|
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||||
|
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||||
|
{{ success }}
|
||||||
|
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.username">
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="user.privileged"
|
||||||
|
@change="toggleAdmin(user, $event)"
|
||||||
|
:disabled="user.username === store.user.username"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
||||||
|
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
||||||
|
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
getServerConfig,
|
||||||
|
listUsers,
|
||||||
|
updatePublic,
|
||||||
|
updateServerName,
|
||||||
|
updateUser
|
||||||
|
} from '@/repositories/User'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string
|
||||||
|
privileged: boolean
|
||||||
|
lastSeen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const success = ref('')
|
||||||
|
const copyButtonText = ref('📋')
|
||||||
|
const serverSettings = reactive({
|
||||||
|
public: false,
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listUsers()
|
||||||
|
users.value = data.users
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to load users')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUser = async () => {
|
||||||
|
const username = window.prompt('Enter username for new user:')
|
||||||
|
if (!username || !username.trim()) return
|
||||||
|
try {
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(username.trim(), undefined, false)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User ${username.trim()} created. Password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to add user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAdmin = async (user: User, event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
try {
|
||||||
|
await updateUser(user.username, { privileged: target.checked })
|
||||||
|
user.privileged = target.checked
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to update user')
|
||||||
|
target.checked = user.privileged // revert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameUser = async (user: User) => {
|
||||||
|
const newName = window.prompt('Enter new username:', user.username)
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === user.username) return
|
||||||
|
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||||
|
// Since no rename endpoint, perhaps delete and create
|
||||||
|
try {
|
||||||
|
success.value = ''
|
||||||
|
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||||
|
await deleteUser(user.username)
|
||||||
|
await loadUsers()
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to rename user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPassword = async (user: User) => {
|
||||||
|
if (
|
||||||
|
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
success.value = ''
|
||||||
|
const result = await updateUser(user.username, { password: '' })
|
||||||
|
if (result.password) {
|
||||||
|
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to reset password')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUserAction = async (username: string) => {
|
||||||
|
if (!confirm(`Delete user ${username}?`)) return
|
||||||
|
try {
|
||||||
|
await deleteUser(username)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to delete user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||||
|
const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
|
||||||
|
if (passwordMatch) {
|
||||||
|
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||||
|
if (isButtonClick) {
|
||||||
|
// Show "Copied!" indication on button
|
||||||
|
copyButtonText.value = '✅ Copied!'
|
||||||
|
// Hide password/key and button immediately after copying
|
||||||
|
const baseMessage = success.value.replace(
|
||||||
|
/(?:Password|New password|Key): .+/,
|
||||||
|
'Copied to clipboard!'
|
||||||
|
)
|
||||||
|
success.value = baseMessage
|
||||||
|
// Hide the entire message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
success.value = ''
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
// Just hide the message when clicking elsewhere
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerSettings = async () => {
|
||||||
|
try {
|
||||||
|
success.value = ''
|
||||||
|
await updatePublic(serverSettings.public)
|
||||||
|
// Update store
|
||||||
|
store.server.public = serverSettings.public
|
||||||
|
success.value = 'Server settings updated'
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to update settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerNameSetting = async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateServerName(serverSettings.name)
|
||||||
|
// Update store with the effective name returned by the server
|
||||||
|
store.server.name = result.name
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to update server name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedUpdateServerName = () => {
|
||||||
|
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
|
||||||
|
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load server config from admin API
|
||||||
|
const loadServerConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await getServerConfig()
|
||||||
|
serverSettings.name = config.name
|
||||||
|
serverSettings.public = config.public
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to store values if API fails
|
||||||
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load users and config when dialog opens
|
||||||
|
watch(
|
||||||
|
() => store.dialog,
|
||||||
|
newVal => {
|
||||||
|
if (newVal === 'usermgmt') {
|
||||||
|
loadServerConfig()
|
||||||
|
if (!store.server.paskia) {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.server.public,
|
||||||
|
newVal => {
|
||||||
|
serverSettings.public = newVal || false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
|
.input-with-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.input-with-hint small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=tokens title="My API Tokens">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
|
||||||
|
|
||||||
|
<!-- Creation form -->
|
||||||
|
<div v-if="mode === 'creating'" class="create-form">
|
||||||
|
<label for="token-name">Token name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="token-name"
|
||||||
|
v-model="newTokenName"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. backup-script"
|
||||||
|
@keyup.enter="submitCreate"
|
||||||
|
ref="nameInput"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
|
||||||
|
<button @click="cancelCreate" class="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation result -->
|
||||||
|
<div v-else-if="mode === 'created' && createdToken" class="created-result">
|
||||||
|
<p class="success-title">✅ Token created</p>
|
||||||
|
<p class="hint">Copy this URL — it will not be shown again.</p>
|
||||||
|
<div class="url-box">
|
||||||
|
<code class="token-url">{{ createdToken.url }}</code>
|
||||||
|
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="finishCreate" class="button primary">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token list -->
|
||||||
|
<div v-else>
|
||||||
|
<button @click="startCreate" class="button" title="Add new token">➕ Add Token</button>
|
||||||
|
<table v-if="tokens.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="token in tokens" :key="token.id">
|
||||||
|
<td>{{ token.name || 'Unnamed' }}</td>
|
||||||
|
<td>{{ formatDate(token.created) }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="empty">You have no API tokens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { createToken, deleteToken, listTokens } from '@/repositories/User'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
sso_user_id: string
|
||||||
|
name: string
|
||||||
|
created: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedToken extends Token {
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const tokens = ref<Token[]>([])
|
||||||
|
const mode = ref<'list' | 'creating' | 'created'>('list')
|
||||||
|
const newTokenName = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
const createdToken = ref<CreatedToken | null>(null)
|
||||||
|
const copyButtonText = ref('📋')
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreate = () => {
|
||||||
|
mode.value = 'list'
|
||||||
|
newTokenName.value = ''
|
||||||
|
creating.value = false
|
||||||
|
createdToken.value = null
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listTokens()
|
||||||
|
tokens.value = data.tokens
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to load tokens')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
mode.value = 'creating'
|
||||||
|
nextTick(() => nameInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureFilesBaseUrl = (url: string) => {
|
||||||
|
const trimmed = url.replace(/\/+$/, '')
|
||||||
|
if (trimmed.endsWith('/files')) return trimmed
|
||||||
|
return `${trimmed}/files`
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const result = await createToken(newTokenName.value)
|
||||||
|
await loadTokens()
|
||||||
|
if (result.url) {
|
||||||
|
createdToken.value = {
|
||||||
|
...(result as CreatedToken),
|
||||||
|
url: ensureFilesBaseUrl((result as CreatedToken).url)
|
||||||
|
}
|
||||||
|
mode.value = 'created'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to create token')
|
||||||
|
mode.value = 'list'
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!createdToken.value) return
|
||||||
|
await navigator.clipboard.writeText(createdToken.value.url)
|
||||||
|
copyButtonText.value = '✅ Copied!'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTokenAction = async (tokenId: string) => {
|
||||||
|
if (!confirm('Revoke this token? It will no longer work.')) return
|
||||||
|
try {
|
||||||
|
await deleteToken(tokenId)
|
||||||
|
await loadTokens()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to revoke token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (ts: number) => {
|
||||||
|
if (!ts) return '—'
|
||||||
|
return new Date(ts * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tokens when dialog opens
|
||||||
|
watch(
|
||||||
|
() => store.dialog,
|
||||||
|
newVal => {
|
||||||
|
if (newVal === 'tokens') {
|
||||||
|
resetCreate()
|
||||||
|
loadTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.create-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.create-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.create-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.create-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.created-result {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
color: #080;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.url-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.token-url {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.dialog-buttons {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -1,21 +1,71 @@
|
|||||||
|
import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
|
||||||
|
|
||||||
|
// Type for API error responses
|
||||||
|
interface ApiError {
|
||||||
|
error: {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasError(msg: unknown): msg is ApiError {
|
||||||
|
return typeof msg === 'object' && msg !== null && 'error' in msg
|
||||||
|
}
|
||||||
|
|
||||||
class ClientClass {
|
class ClientClass {
|
||||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
async get(url: string): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
|
||||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, { method: 'GET' })
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
|
try {
|
||||||
|
const msg = await apiJson(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
|
try {
|
||||||
|
const msg = await apiJson(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async delete(url: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const msg = await apiJson(url, { method: 'DELETE' })
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { apiFetch }
|
||||||
export default Client
|
export default Client
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { FILE_TYPES, formatSize, formatUnixDate } from '@/utils'
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -7,51 +8,119 @@ export type DocProps = {
|
|||||||
name: string
|
name: string
|
||||||
key: FUID
|
key: FUID
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
|
ghost?: boolean
|
||||||
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
|
ar?: number // Aspect ratio (height/width) from server, if known
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
private _name: string = ""
|
public loc: string = ''
|
||||||
public loc: string = ""
|
public key: FUID = ''
|
||||||
public key: FUID = ""
|
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
|
public allocated: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
|
public ghost: boolean = false
|
||||||
|
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||||
|
/** @internal Use the name getter/setter instead */
|
||||||
|
public _name: string = ''
|
||||||
|
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
get name() { return this._name }
|
const { name, ...rest } = props
|
||||||
|
Object.assign(this, rest)
|
||||||
|
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||||
|
}
|
||||||
|
get name() {
|
||||||
|
return this._name
|
||||||
|
}
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
this.haystack = haystackFormat(name)
|
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string {
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
return formatSize(this.size)
|
||||||
|
}
|
||||||
|
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||||
|
get sparseIndicator(): string {
|
||||||
|
if (this.dir || this.size <= this.allocated) return ''
|
||||||
|
if (this.allocated === 0) return '⭕' // exactly zero
|
||||||
|
const ratio = this.allocated / this.size
|
||||||
|
// Round to nearest 25%: ◔◑◕⬤
|
||||||
|
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||||
|
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||||
|
}
|
||||||
|
get modified(): string {
|
||||||
|
return formatUnixDate(this.mtime)
|
||||||
|
}
|
||||||
get url(): string {
|
get url(): string {
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
return this.dir
|
||||||
|
? '/#/' + `${p}/`.replaceAll('#', '%23')
|
||||||
|
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
}
|
}
|
||||||
get urlrouter(): string {
|
get urlrouter(): string {
|
||||||
return this.url.replace(/^\/#/, '')
|
return this.url.replace(/^\/#/, '')
|
||||||
}
|
}
|
||||||
get img(): boolean {
|
get img(): boolean {
|
||||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
return (
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
!this.dir && (FILE_TYPES.imageBrowser as readonly string[]).includes(this.ext)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
get video(): boolean {
|
||||||
|
return (FILE_TYPES.video as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get audio(): boolean {
|
||||||
|
return (FILE_TYPES.audio as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get archive(): boolean {
|
||||||
|
return (FILE_TYPES.archive as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get document(): boolean {
|
||||||
|
return (FILE_TYPES.document as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
|
get image(): boolean {
|
||||||
|
return (FILE_TYPES.image as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get print(): boolean {
|
||||||
|
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get text(): boolean {
|
||||||
|
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get editurl(): string {
|
||||||
|
if (!this.text) return ''
|
||||||
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
|
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
}
|
||||||
|
get complete(): boolean {
|
||||||
|
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||||
}
|
}
|
||||||
get previewable(): boolean {
|
get previewable(): boolean {
|
||||||
if (this.img) return true
|
if (this.dir) return false
|
||||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
return (
|
||||||
// Not a comprehensive list, but good enough for now
|
this.img ||
|
||||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(ext || '')
|
this.video ||
|
||||||
|
this.audio ||
|
||||||
|
this.image ||
|
||||||
|
this.print ||
|
||||||
|
(this.document && useMainStore().server.office_previews !== false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
return this.url.replace(/^\/files/, '/preview')
|
return !this.complete || !this.previewable
|
||||||
|
? ''
|
||||||
|
: this.url.replace(/^\/files/, '/preview')
|
||||||
}
|
}
|
||||||
get ext(): string {
|
get ext(): string {
|
||||||
const ext = this.name.split('.').pop()
|
const dotIndex = this.name.lastIndexOf('.')
|
||||||
return ext ? ext.toLowerCase() : ''
|
return dotIndex === -1 || dotIndex === this.name.length - 1
|
||||||
|
? ''
|
||||||
|
: this.name.slice(dotIndex + 1).toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type errorEvent = {
|
export type errorEvent = {
|
||||||
@@ -65,12 +134,14 @@ export type errorEvent = {
|
|||||||
// Raw types the backend /api/watch sends us
|
// Raw types the backend /api/watch sends us
|
||||||
|
|
||||||
export type FileEntry = [
|
export type FileEntry = [
|
||||||
number, // level
|
number, // level
|
||||||
string, // name
|
string, // name
|
||||||
FUID,
|
FUID,
|
||||||
number, //mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
|
number, // allocated (actual disk usage)
|
||||||
number, // isfile
|
number, // isfile
|
||||||
|
number? // ar: aspect ratio (height/width), present if known
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Client from '@/repositories/Client'
|
import Client from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
export const url_login = '/login'
|
export const url_login = '/auth/login'
|
||||||
export const url_logout = '/logout'
|
export const url_logout = '/auth/api/logout'
|
||||||
export const url_password = '/password-change'
|
export const url_password = '/auth/password-change'
|
||||||
|
|
||||||
export async function loginUser(username: string, password: string) {
|
export async function loginUser(username: string, password: string) {
|
||||||
const user = await Client.post(url_login, {
|
const user = await Client.post(url_login, {
|
||||||
@@ -16,7 +16,11 @@ export async function logoutUser() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changePassword(username: string, passwordChange: string, password: string) {
|
export async function changePassword(
|
||||||
|
username: string,
|
||||||
|
passwordChange: string,
|
||||||
|
password: string
|
||||||
|
) {
|
||||||
const data = await Client.post(url_password, {
|
const data = await Client.post(url_password, {
|
||||||
username,
|
username,
|
||||||
passwordChange,
|
passwordChange,
|
||||||
@@ -24,3 +28,79 @@ export async function changePassword(username: string, passwordChange: string, p
|
|||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const url_users = '/auth/users'
|
||||||
|
|
||||||
|
export async function listUsers() {
|
||||||
|
const data = await Client.get(url_users)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
username: string,
|
||||||
|
password?: string,
|
||||||
|
privileged?: boolean
|
||||||
|
) {
|
||||||
|
const data = await Client.post(url_users, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privileged
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
username: string,
|
||||||
|
changes: { password?: string; privileged?: boolean }
|
||||||
|
) {
|
||||||
|
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string) {
|
||||||
|
const data = await Client.delete(`${url_users}/${username}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublic(isPublic: boolean) {
|
||||||
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServerName(name: string) {
|
||||||
|
const data = await Client.put('/api/config/name', { name })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerConfig() {
|
||||||
|
const data = await Client.get('/api/config')
|
||||||
|
return data as { name: string; public: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const url_tokens = '/api/tokens'
|
||||||
|
|
||||||
|
export async function listTokens() {
|
||||||
|
const data = await Client.get(url_tokens)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(name: string) {
|
||||||
|
const data = await Client.post(url_tokens, { name })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteToken(tokenId: string) {
|
||||||
|
const data = await Client.delete(`${url_tokens}/${tokenId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShareToken(paths: string[], mode: 'ro' | 'rw' = 'ro') {
|
||||||
|
const data = await Client.post('/api/share-tokens', { paths, mode })
|
||||||
|
return data as {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
mode: 'ro' | 'rw'
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useMainStore } from "@/stores/main"
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
|
||||||
|
import type { FileEntry, UpdateEntry, errorEvent } from './Document'
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
|
||||||
export const uploadUrl = '/api/upload'
|
|
||||||
export const watchUrl = '/api/watch'
|
export const watchUrl = '/api/watch'
|
||||||
|
|
||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
let reconnDelay = 500
|
let reconnDelay = 500
|
||||||
let wsWatch = null as WebSocket | null
|
let wsWatch = null as WebSocket | null
|
||||||
|
// Track when we're awaiting authentication to prevent reconnection loops
|
||||||
|
let awaitingAuth = false
|
||||||
|
|
||||||
|
// Clear the local tree cache (called on logout/auth failure)
|
||||||
|
export const clearTree = () => {
|
||||||
|
tree = []
|
||||||
|
}
|
||||||
|
|
||||||
export const loadSession = () => {
|
export const loadSession = () => {
|
||||||
const s = localStorage['cista-files']
|
const s = localStorage['cista-files']
|
||||||
@@ -19,21 +25,63 @@ export const loadSession = () => {
|
|||||||
console.log(`Loaded session with ${tree.length} items cached`)
|
console.log(`Loaded session with ${tree.length} items cached`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Loading session failed", error)
|
console.log('Loading session failed', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSession = () => {
|
const saveSession = () => {
|
||||||
localStorage["cista-files"] = JSON.stringify(tree)
|
localStorage['cista-files'] = JSON.stringify(tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
export const connect = (
|
||||||
|
path: string,
|
||||||
|
handlers: Partial<Record<keyof WebSocketEventMap, any>>
|
||||||
|
) => {
|
||||||
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
||||||
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
|
for (const [event, handler] of Object.entries(handlers))
|
||||||
|
webSocket.addEventListener(event, handler)
|
||||||
return webSocket
|
return webSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
|
||||||
|
async function handleWsAuthError(msg: any) {
|
||||||
|
const iframe = msg.error?.auth?.iframe
|
||||||
|
if (iframe) {
|
||||||
|
// Clear sensitive data immediately on auth failure
|
||||||
|
const store = useMainStore()
|
||||||
|
store.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
// Stop reconnection attempts while showing auth dialog
|
||||||
|
awaitingAuth = true
|
||||||
|
store.authInProgress = true
|
||||||
|
store.error = '' // Clear any connection message
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await showAuthIframe(iframe)
|
||||||
|
// Auth succeeded - reconnect
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
watchConnect()
|
||||||
|
} catch (e) {
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
console.log('User cancelled authentication')
|
||||||
|
// Show access denied dialog
|
||||||
|
store.dialog = 'accessdenied'
|
||||||
|
} else {
|
||||||
|
console.error('Auth iframe error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const watchConnect = () => {
|
export const watchConnect = () => {
|
||||||
if (watchTimeout !== null) {
|
if (watchTimeout !== null) {
|
||||||
clearTimeout(watchTimeout)
|
clearTimeout(watchTimeout)
|
||||||
@@ -45,21 +93,21 @@ export const watchConnect = () => {
|
|||||||
|
|
||||||
wsWatch = connect(watchUrl, {
|
wsWatch = connect(watchUrl, {
|
||||||
message: handleWatchMessage,
|
message: handleWatchMessage,
|
||||||
close: watchReconnect,
|
close: watchReconnect
|
||||||
})
|
})
|
||||||
wsWatch.addEventListener("message", event => {
|
wsWatch.addEventListener('message', event => {
|
||||||
if (store.connected) return
|
if (store.connected) return
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg)
|
||||||
} else {
|
} else {
|
||||||
store.error = msg.error.message
|
store.error = msg.error.message
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ("server" in msg) {
|
if ('server' in msg) {
|
||||||
console.log('Connected to backend', msg)
|
console.log('Connected to backend', msg)
|
||||||
store.server = msg.server
|
store.server = msg.server
|
||||||
store.connected = true
|
store.connected = true
|
||||||
@@ -67,7 +115,6 @@ export const watchConnect = () => {
|
|||||||
store.error = ''
|
store.error = ''
|
||||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||||
else if (store.isUserLogged) store.logout()
|
else if (store.isUserLogged) store.logout()
|
||||||
if (!msg.server.public && !msg.user) store.dialog = 'login'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,27 +125,36 @@ export const watchDisconnect = () => {
|
|||||||
wsWatch = null
|
wsWatch = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset auth state and reconnect - call after successful authentication
|
||||||
|
export const resumeWatching = () => {
|
||||||
|
awaitingAuth = false
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
watchConnect()
|
||||||
|
}
|
||||||
|
|
||||||
let watchTimeout: any = null
|
let watchTimeout: any = null
|
||||||
|
|
||||||
const watchReconnect = (event: MessageEvent) => {
|
const watchReconnect = (event: MessageEvent) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Don't reconnect if we're awaiting authentication or auth iframe is showing
|
||||||
|
if (awaitingAuth || isAuthIframeOpen()) {
|
||||||
|
console.log('Skipping reconnect - awaiting authentication')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (store.connected) {
|
if (store.connected) {
|
||||||
console.warn("Disconnected from server", event)
|
console.warn('Disconnected from server', event)
|
||||||
store.connected = false
|
store.connected = false
|
||||||
store.error = 'Reconnecting...'
|
store.error = 'Reconnecting...'
|
||||||
}
|
}
|
||||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||||
// Don't hammer the server while on login dialog
|
|
||||||
if (store.dialog === 'login') {
|
|
||||||
watchTimeout = setTimeout(watchReconnect, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reconnDelay = Math.min(5000, reconnDelay + 500)
|
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleWatchMessage = (event: MessageEvent) => {
|
const handleWatchMessage = (event: MessageEvent) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
switch (true) {
|
switch (true) {
|
||||||
@@ -108,8 +164,14 @@ const handleWatchMessage = (event: MessageEvent) => {
|
|||||||
case !!msg.update:
|
case !!msg.update:
|
||||||
handleUpdateMessage(msg)
|
handleUpdateMessage(msg)
|
||||||
break
|
break
|
||||||
|
case !!msg.ar: {
|
||||||
|
const store = useMainStore()
|
||||||
|
store.updateAr(msg.ar as Record<string, number>)
|
||||||
|
break
|
||||||
|
}
|
||||||
case !!msg.space:
|
case !!msg.space:
|
||||||
console.log('Watch space', msg.space)
|
const store = useMainStore()
|
||||||
|
store.space = msg.space
|
||||||
break
|
break
|
||||||
case !!msg.error:
|
case !!msg.error:
|
||||||
handleError(msg)
|
handleError(msg)
|
||||||
@@ -138,13 +200,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
if (action === 'k') {
|
if (action === 'k') {
|
||||||
newtree.push(...tree.slice(oidx, oidx + arg))
|
newtree.push(...tree.slice(oidx, oidx + arg))
|
||||||
oidx += arg
|
oidx += arg
|
||||||
}
|
} else if (action === 'd') oidx += arg
|
||||||
else if (action === 'd') oidx += arg
|
|
||||||
else if (action === 'i') newtree.push(...arg)
|
else if (action === 'i') newtree.push(...arg)
|
||||||
else console.log("Unknown update action", action, arg)
|
else console.log('Unknown update action', action, arg)
|
||||||
}
|
}
|
||||||
if (oidx != tree.length)
|
if (oidx != tree.length)
|
||||||
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
|
throw Error(
|
||||||
|
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
|
||||||
|
)
|
||||||
store.updateRoot(newtree)
|
store.updateRoot(newtree)
|
||||||
tree = newtree
|
tree = newtree
|
||||||
saveSession()
|
saveSession()
|
||||||
@@ -152,9 +215,9 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
|
|
||||||
function handleError(msg: errorEvent) {
|
function handleError(msg: errorEvent) {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg as any)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { useMainStore } from '@/stores/main'
|
||||||
import ExplorerView from '@/views/ExplorerView.vue'
|
import ExplorerView from '@/views/ExplorerView.vue'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
function getPathDepth(path: string): number {
|
||||||
|
const pathPart = decodeURIComponent(path).split('//')[0] ?? ''
|
||||||
|
return pathPart.split('/').filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
@@ -12,4 +18,17 @@ const router = createRouter({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
const store = useMainStore()
|
||||||
|
const toDepth = getPathDepth(to.path)
|
||||||
|
const fromDepth = getPathDepth(from.path)
|
||||||
|
if (toDepth > fromDepth) {
|
||||||
|
store.transitionDirection = 'forward'
|
||||||
|
} else if (toDepth < fromDepth) {
|
||||||
|
store.transitionDirection = 'backward'
|
||||||
|
} else {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Non-reactive document storage for the full file list
|
||||||
|
// This avoids Vue reactivity overhead on large arrays
|
||||||
|
|
||||||
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { shallowRef, triggerRef } from 'vue'
|
||||||
|
|
||||||
|
// The main document list - shallowRef means only the reference is reactive, not the contents
|
||||||
|
const documents = shallowRef<Doc[]>([])
|
||||||
|
|
||||||
|
// Version counter for manual reactivity triggering
|
||||||
|
let version = 0
|
||||||
|
|
||||||
|
export function getDocuments(): Doc[] {
|
||||||
|
return documents.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDocuments(docs: Doc[]): void {
|
||||||
|
documents.value = docs
|
||||||
|
version++
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVersion(): number {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger reactivity manually (e.g., after modifications)
|
||||||
|
export function triggerUpdate(): void {
|
||||||
|
version++
|
||||||
|
triggerRef(documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For computed dependencies that need to react to document changes
|
||||||
|
export const documentRef = documents
|
||||||
@@ -1,39 +1,115 @@
|
|||||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
import type { FUID, FileEntry, SelectedItems } from '@/repositories/Document'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { defineStore, type StateTree } from 'pinia'
|
import { resumeWatching, watchConnect } from '@/repositories/WS'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { logoutUser } from '@/repositories/User'
|
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||||
import { watchConnect } from '@/repositories/WS'
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
import { shallowRef } from 'vue'
|
import { type StateTree, defineStore } from 'pinia'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
|
||||||
|
|
||||||
export const useMainStore = defineStore({
|
// Singleton search worker instance
|
||||||
id: 'main',
|
let searchWorker: Worker | null = null
|
||||||
|
let searchId = 0
|
||||||
|
let searchStore: ReturnType<typeof useMainStore> | null = null
|
||||||
|
let loadingTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let clearOldResultsTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastResultUpdate = 0
|
||||||
|
|
||||||
|
function getSearchWorker(): Worker {
|
||||||
|
if (!searchWorker) {
|
||||||
|
searchWorker = new SearchWorker()
|
||||||
|
// Set up message handler once
|
||||||
|
searchWorker.onmessage = e => {
|
||||||
|
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||||
|
|
||||||
|
// Convert plain data back to Doc instances
|
||||||
|
const docs = e.data.docs.map((d: any) => new Doc(d))
|
||||||
|
|
||||||
|
// Cancel the clear-old-results timer since we have new results
|
||||||
|
if (clearOldResultsTimer) {
|
||||||
|
clearTimeout(clearOldResultsTimer)
|
||||||
|
clearOldResultsTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle rapid intermediate updates to reduce UI flicker
|
||||||
|
const now = performance.now()
|
||||||
|
if (!e.data.done && now - lastResultUpdate < 50) {
|
||||||
|
return // Skip intermediate update if too recent
|
||||||
|
}
|
||||||
|
lastResultUpdate = now
|
||||||
|
|
||||||
|
searchStore.searchResults = docs
|
||||||
|
|
||||||
|
if (e.data.done) {
|
||||||
|
// Clear the loading timer and hide spinner
|
||||||
|
if (loadingTimer) {
|
||||||
|
clearTimeout(loadingTimer)
|
||||||
|
loadingTimer = null
|
||||||
|
}
|
||||||
|
searchStore.searchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return searchWorker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghost expiry time in seconds
|
||||||
|
const GHOST_TTL = 30
|
||||||
|
|
||||||
|
// Periodic cleanup interval
|
||||||
|
let cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: shallowRef<Doc[]>([]),
|
// Ghosts are temporary optimistic-update files/folders shown until server confirms
|
||||||
|
ghosts: [] as Doc[],
|
||||||
|
// Hidden paths for optimistic delete (path -> expiry timestamp)
|
||||||
|
hiddenPaths: new Map<string, number>(),
|
||||||
|
// Version counter to trigger reactivity when external document list changes
|
||||||
|
docVersion: 0,
|
||||||
selected: new Set<FUID>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
|
searchResults: [] as Doc[],
|
||||||
|
searchLoading: false,
|
||||||
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||||
|
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||||
|
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any> & {
|
||||||
dialog: '' as '' | 'login' | 'settings',
|
public?: boolean
|
||||||
|
paskia?: boolean
|
||||||
|
office_previews?: boolean
|
||||||
|
},
|
||||||
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
gallery: false,
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
|
searchHotkey: '/' // Character shown for search hotkey (Slash key)
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '' as string,
|
username: '' as string,
|
||||||
privileged: false as boolean,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false as boolean,
|
isLoggedIn: false as boolean
|
||||||
|
},
|
||||||
|
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
|
||||||
|
editorSave: null as null | (() => void),
|
||||||
|
space: {
|
||||||
|
disk: 0,
|
||||||
|
free: 0,
|
||||||
|
used: 0,
|
||||||
|
storage: 0,
|
||||||
|
allocated: 0
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['prefs', 'cursor', 'selected'],
|
pick: ['prefs', 'cursor', 'selected'],
|
||||||
serializer: {
|
serializer: {
|
||||||
deserialize: (data: string): StateTree => {
|
deserialize: (data: string): StateTree => {
|
||||||
const ret = JSON.parse(data)
|
const ret = JSON.parse(data)
|
||||||
@@ -44,75 +120,294 @@ export const useMainStore = defineStore({
|
|||||||
tree.selected = Array.from(tree.selected)
|
tree.selected = Array.from(tree.selected)
|
||||||
return JSON.stringify(tree)
|
return JSON.stringify(tree)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(new Doc({
|
docs.push(
|
||||||
name,
|
new Doc({
|
||||||
loc: level ? loc.join('/') : '/',
|
name,
|
||||||
key,
|
loc: level ? loc.join('/') : '/',
|
||||||
size,
|
key,
|
||||||
mtime,
|
size,
|
||||||
dir: !isfile,
|
allocated,
|
||||||
}))
|
mtime,
|
||||||
|
dir: !isfile,
|
||||||
|
ar
|
||||||
|
})
|
||||||
|
)
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs
|
// Store in non-reactive external storage
|
||||||
|
setDocuments(docs)
|
||||||
|
// Clear ghosts that now exist in the real list
|
||||||
|
const realPaths = new Set(docs.map(d => (d.loc ? `${d.loc}/${d.name}` : d.name)))
|
||||||
|
this.ghosts = this.ghosts.filter(
|
||||||
|
g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name)
|
||||||
|
)
|
||||||
|
// Clear hidden paths that no longer exist (deletion confirmed)
|
||||||
|
for (const path of this.hiddenPaths.keys()) {
|
||||||
|
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
|
||||||
|
}
|
||||||
|
// Start cleanup timer if not running
|
||||||
|
this.startCleanupTimer()
|
||||||
|
// Bump version to trigger reactive updates
|
||||||
|
this.docVersion++
|
||||||
|
// Sync documents to search worker
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
|
/** Patch aspect ratios on existing docs from a server ar update message */
|
||||||
|
updateAr(arMap: Record<string, number>) {
|
||||||
|
const docs = getDocuments()
|
||||||
|
let changed = false
|
||||||
|
for (const doc of docs) {
|
||||||
|
const ar = arMap[doc.key]
|
||||||
|
if (ar != null && doc.ar !== ar) {
|
||||||
|
doc.ar = ar
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Add a ghost file/folder for optimistic UI updates */
|
||||||
|
addGhost(doc: Doc) {
|
||||||
|
doc.ghost = true
|
||||||
|
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
|
||||||
|
this.ghosts.push(doc)
|
||||||
|
},
|
||||||
|
/** Clear all ghosts (e.g., on navigation or refresh) */
|
||||||
|
clearGhosts() {
|
||||||
|
this.ghosts = []
|
||||||
|
},
|
||||||
|
/** Hide a document path (optimistic delete) */
|
||||||
|
hideDoc(path: string) {
|
||||||
|
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
|
||||||
|
},
|
||||||
|
/** Unhide a document path (delete failed, restore visibility) */
|
||||||
|
unhideDoc(path: string) {
|
||||||
|
this.hiddenPaths.delete(path)
|
||||||
|
},
|
||||||
|
/** Start the periodic cleanup timer */
|
||||||
|
startCleanupTimer() {
|
||||||
|
if (cleanupInterval) return
|
||||||
|
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
|
||||||
|
},
|
||||||
|
/** Stop the cleanup timer */
|
||||||
|
stopCleanupTimer() {
|
||||||
|
if (cleanupInterval) {
|
||||||
|
clearInterval(cleanupInterval)
|
||||||
|
cleanupInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Remove expired ghosts and hidden paths */
|
||||||
|
cleanupExpired() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const ghostsBefore = this.ghosts.length
|
||||||
|
const hiddenBefore = this.hiddenPaths.size
|
||||||
|
this.ghosts = this.ghosts.filter(g => g.expires > now)
|
||||||
|
for (const [path, expires] of this.hiddenPaths) {
|
||||||
|
if (expires <= now) this.hiddenPaths.delete(path)
|
||||||
|
}
|
||||||
|
// Stop timer if nothing to clean up
|
||||||
|
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
|
showToast(message: string, duration = 3000) {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = message
|
||||||
|
this.toastTimeout = setTimeout(() => {
|
||||||
|
this.toast = ''
|
||||||
|
this.toastTimeout = null
|
||||||
|
}, duration)
|
||||||
|
},
|
||||||
|
/** Clear the current toast immediately */
|
||||||
|
clearToast() {
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout)
|
||||||
|
this.toastTimeout = null
|
||||||
|
}
|
||||||
|
this.toast = ''
|
||||||
|
},
|
||||||
|
syncSearchWorker() {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
// Send plain data to worker (no class instances)
|
||||||
|
const docs = getDocuments()
|
||||||
|
const docData = docs.map(doc => ({
|
||||||
|
loc: doc.loc,
|
||||||
|
name: doc.name,
|
||||||
|
key: doc.key,
|
||||||
|
size: doc.size,
|
||||||
|
allocated: doc.allocated,
|
||||||
|
mtime: doc.mtime,
|
||||||
|
dir: doc.dir
|
||||||
|
}))
|
||||||
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
|
},
|
||||||
|
/** Notify UI/search that existing document objects were mutated in-place */
|
||||||
|
documentsChanged() {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
|
search(query: string, loc: string) {
|
||||||
|
const worker = getSearchWorker()
|
||||||
|
const id = ++searchId
|
||||||
|
searchStore = this // Store reference for worker callback
|
||||||
|
|
||||||
|
// Update query immediately so watchers know we're handling this
|
||||||
|
this.query = query
|
||||||
|
|
||||||
|
// Cancel pending timers
|
||||||
|
if (loadingTimer) {
|
||||||
|
clearTimeout(loadingTimer)
|
||||||
|
loadingTimer = null
|
||||||
|
}
|
||||||
|
if (clearOldResultsTimer) {
|
||||||
|
clearTimeout(clearOldResultsTimer)
|
||||||
|
clearOldResultsTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
// Clear results only when search is closed
|
||||||
|
this.searchResults = []
|
||||||
|
this.searchLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep old results briefly to avoid flicker on fast cached searches
|
||||||
|
// But clear them after 50ms if no new results have arrived
|
||||||
|
clearOldResultsTimer = setTimeout(() => {
|
||||||
|
if (searchId === id) {
|
||||||
|
this.searchResults = []
|
||||||
|
}
|
||||||
|
clearOldResultsTimer = null
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
// Delay showing loading indicator to avoid flicker on fast searches
|
||||||
|
loadingTimer = setTimeout(() => {
|
||||||
|
if (searchId === id) {
|
||||||
|
// Still the current search
|
||||||
|
this.searchLoading = true
|
||||||
|
}
|
||||||
|
loadingTimer = null
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
worker.postMessage({ type: 'search', query, loc, id })
|
||||||
},
|
},
|
||||||
login(username: string, privileged: boolean) {
|
login(username: string, privileged: boolean) {
|
||||||
this.user.username = username
|
this.user.username = username
|
||||||
this.user.privileged = privileged
|
this.user.privileged = privileged
|
||||||
this.user.isLoggedIn = true
|
this.user.isLoggedIn = true
|
||||||
this.dialog = ''
|
this.dialog = ''
|
||||||
if (!this.connected) watchConnect()
|
if (!this.connected) resumeWatching()
|
||||||
},
|
},
|
||||||
loginDialog() {
|
clearSensitiveData() {
|
||||||
this.dialog = 'login'
|
// Clear all sensitive state on logout or auth failure
|
||||||
|
localStorage.removeItem('cista-files')
|
||||||
|
setDocuments([])
|
||||||
|
this.ghosts = []
|
||||||
|
this.hiddenPaths.clear()
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
this.docVersion++
|
||||||
|
this.selected.clear()
|
||||||
|
this.user.username = ''
|
||||||
|
this.user.privileged = false
|
||||||
|
this.user.isLoggedIn = false
|
||||||
|
this.connected = false
|
||||||
|
this.dialog = ''
|
||||||
|
this.cursor = ''
|
||||||
|
this.editorSave = null
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Logout")
|
console.log('Logout')
|
||||||
await logoutUser()
|
try {
|
||||||
this.$reset()
|
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||||
localStorage.clear()
|
if (!res.ok) {
|
||||||
history.go() // Reload page
|
const data = await res.json().catch(() => ({}))
|
||||||
|
this.error = data.message || data.detail || 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.clearSensitiveData()
|
||||||
|
resumeWatching()
|
||||||
},
|
},
|
||||||
toggleSort(name: SortOrder) {
|
toggleSort(name: SortOrder) {
|
||||||
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||||
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
|
const newOrder = current === name ? '' : name
|
||||||
|
if (this.query) this.prefs.sortFiltered = newOrder
|
||||||
|
else this.prefs.sortListing = newOrder
|
||||||
|
this.showSortToast(newOrder)
|
||||||
},
|
},
|
||||||
sort(name: SortOrder | '') {
|
sort(name: SortOrder | '') {
|
||||||
if (this.query) this.prefs.sortFiltered = name
|
if (this.query) this.prefs.sortFiltered = name
|
||||||
else this.prefs.sortListing = name
|
else this.prefs.sortListing = name
|
||||||
|
this.showSortToast(name)
|
||||||
|
},
|
||||||
|
showSortToast(order: SortOrder | '') {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'': 'Folders first',
|
||||||
|
name: 'Alphabetical order',
|
||||||
|
modified: 'Newest first',
|
||||||
|
size: 'Largest first'
|
||||||
|
}
|
||||||
|
this.showToast(labels[order] || order, 1200)
|
||||||
},
|
},
|
||||||
focusBreadcrumb() {
|
focusBreadcrumb() {
|
||||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||||
},
|
},
|
||||||
cancelDownloads() {
|
cancelDownloads() {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
},
|
},
|
||||||
cancelUploads() {
|
cancelUploads() {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
sortOrder(): SortOrder {
|
||||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||||
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
|
},
|
||||||
|
isUserLogged(): boolean {
|
||||||
|
return this.user.isLoggedIn
|
||||||
|
},
|
||||||
|
/** Get documents count (triggers on docVersion change) */
|
||||||
|
documentCount(): number {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
return getDocuments().length
|
||||||
|
},
|
||||||
|
recentDocuments(): Doc[] {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
return sorted(getDocuments(), 'modified')
|
||||||
|
},
|
||||||
selectedFiles(): SelectedItems {
|
selectedFiles(): SelectedItems {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
const docs = getDocuments()
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
const found = new Set<FUID>()
|
const found = new Set<FUID>()
|
||||||
const ret: SelectedItems = {
|
const ret: SelectedItems = {
|
||||||
missing: new Set(),
|
missing: new Set(),
|
||||||
docs: {},
|
docs: {},
|
||||||
keys: [],
|
keys: [],
|
||||||
recursive: [],
|
recursive: []
|
||||||
}
|
}
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (selected.has(doc.key)) {
|
if (selected.has(doc.key)) {
|
||||||
found.add(doc.key)
|
found.add(doc.key)
|
||||||
ret.keys.push(doc.key)
|
ret.keys.push(doc.key)
|
||||||
@@ -122,22 +417,19 @@ export const useMainStore = defineStore({
|
|||||||
// What did we not select?
|
// What did we not select?
|
||||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||||
// Build a flat list including contents recursively
|
// Build a flat list including contents recursively
|
||||||
const relnames = new Set<string>()
|
|
||||||
function add(rel: string, full: string, doc: Doc) {
|
|
||||||
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
|
||||||
relnames.add(rel)
|
|
||||||
ret.recursive.push([rel, full, doc])
|
|
||||||
}
|
|
||||||
for (const key of ret.keys) {
|
for (const key of ret.keys) {
|
||||||
const base = ret.docs[key]
|
const base = ret.docs[key]!
|
||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
ret.recursive.push([base.name, basepath, base])
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
if (
|
||||||
|
doc.loc === basepath ||
|
||||||
|
(doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/')
|
||||||
|
) {
|
||||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
const rel = full.slice(nremove)
|
const rel = full.slice(nremove)
|
||||||
add(rel, full, doc)
|
ret.recursive.push([rel, full, doc])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { clearTree } from '@/repositories/WS'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMainStore } from './main'
|
||||||
|
|
||||||
|
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||||
|
const isExternalAuth = computed(() => {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
return mainStore.server?.paskia === true
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearDataOnUnauth() {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
mainStore.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isExternalAuth, clearDataOnUnauth }
|
||||||
|
})
|
||||||
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
|
|||||||
sorted.sort(ordering[order])
|
sorted.sort(ordering[order])
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort documents while keeping files grouped by their folder.
|
||||||
|
* - name: folders sorted by folder path, items within by name
|
||||||
|
* - modified: folders sorted by newest item within results, items within by mtime
|
||||||
|
* - size: folders sorted by largest file within results, items within by size
|
||||||
|
*/
|
||||||
|
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
|
||||||
|
if (!order) return documents
|
||||||
|
|
||||||
|
const compare = ordering[order]
|
||||||
|
|
||||||
|
// Group documents by their folder location
|
||||||
|
const byFolder = new Map<string, Doc[]>()
|
||||||
|
for (const doc of documents) {
|
||||||
|
const folder = doc.loc
|
||||||
|
if (!byFolder.has(folder)) byFolder.set(folder, [])
|
||||||
|
byFolder.get(folder)!.push(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items within each folder
|
||||||
|
for (const docs of byFolder.values()) {
|
||||||
|
docs.sort(compare)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the "best" item in each folder (first after sorting = best according to criteria)
|
||||||
|
const folderBest = new Map<string, Doc>()
|
||||||
|
for (const [folder, docs] of byFolder) {
|
||||||
|
folderBest.set(folder, docs[0]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort folders: by path for name sort, by best item for modified/size
|
||||||
|
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
|
||||||
|
if (order === 'name') {
|
||||||
|
return collator.compare(a, b)
|
||||||
|
}
|
||||||
|
return compare(folderBest.get(a)!, folderBest.get(b)!)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flatten back into a single array with folder grouping preserved
|
||||||
|
const result: Doc[] = []
|
||||||
|
for (const folder of sortedFolders) {
|
||||||
|
result.push(...byFolder.get(folder)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,61 @@
|
|||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
|
||||||
export const exists = (path: string[]) => {
|
export const exists = (path: string[]) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void store.docVersion
|
||||||
|
if (path.length === 0) return true
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
const hidden = store.hiddenPaths
|
||||||
|
const inDocs = getDocuments().some(doc => {
|
||||||
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
|
if (inDocs) return true
|
||||||
|
return store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
export const stripExt = (name: string): string => {
|
||||||
|
// Common compound extensions
|
||||||
|
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
for (const ext of compoundExts) {
|
||||||
|
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
|
||||||
|
}
|
||||||
|
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
|
||||||
|
const lastDot = name.lastIndexOf('.')
|
||||||
|
if (lastDot > 0) {
|
||||||
|
const ext = name.slice(lastDot + 1)
|
||||||
|
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
|
||||||
|
return name.slice(0, lastDot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a sensible zip filename for a selection of items */
|
||||||
|
export const zipName = (items: { name: string; loc: string }[]): string => {
|
||||||
|
const names = items.map(d => d.name)
|
||||||
|
if (names.length === 1) {
|
||||||
|
// Single item - use its name
|
||||||
|
return stripExt(names[0]!)
|
||||||
|
}
|
||||||
|
// Check if all items share the same direct parent folder
|
||||||
|
const locs = items.map(d => d.loc)
|
||||||
|
const sameLoc = locs.every(loc => loc === locs[0])
|
||||||
|
if (sameLoc && locs[0]) {
|
||||||
|
// All items in same folder - use folder name
|
||||||
|
return locs[0].split('/').pop()!
|
||||||
|
}
|
||||||
|
if (names.length <= 3) {
|
||||||
|
// Few items from different folders - join basenames with dot
|
||||||
|
return names.map(stripExt).join('.')
|
||||||
|
}
|
||||||
|
// Many items from different folders - first basename + indicator
|
||||||
|
return `${stripExt(names[0]!)}.etc`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,71 +26,164 @@ export function formatUnixDate(t: number) {
|
|||||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||||
if (adiff <= 5000) return 'now'
|
if (adiff <= 5000) return 'now'
|
||||||
if (adiff <= 60000) {
|
if (adiff <= 60000) {
|
||||||
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 1000), 'second')
|
||||||
|
.replace(' ago', '')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 3600000) {
|
if (adiff <= 3600000) {
|
||||||
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 60000), 'minute')
|
||||||
|
.replace('utes', '')
|
||||||
|
.replace('ute', '')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 86400000) {
|
if (adiff <= 86400000) {
|
||||||
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 3600000), 'hour')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 604800000) {
|
if (adiff <= 604800000) {
|
||||||
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 86400000), 'day')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
let d = date.toLocaleDateString('en-ie', {
|
let d = date
|
||||||
weekday: 'short',
|
.toLocaleDateString('en-ie', {
|
||||||
year: 'numeric',
|
weekday: 'short',
|
||||||
month: 'short',
|
year: 'numeric',
|
||||||
day: 'numeric'
|
month: 'short',
|
||||||
}).replace("Sept", "Sep")
|
day: 'numeric'
|
||||||
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
})
|
||||||
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
.replace('Sept', 'Sep')
|
||||||
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
||||||
|
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
||||||
|
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileExtension(filename: string) {
|
export function getFileExtension(filename: string) {
|
||||||
const parts = filename.split('.')
|
const dotIndex = filename.lastIndexOf('.')
|
||||||
if (parts.length > 1) {
|
if (dotIndex === -1 || dotIndex === filename.length - 1) {
|
||||||
return parts[parts.length - 1]
|
return '' // No extension
|
||||||
} else {
|
|
||||||
return '' // No hay extensión
|
|
||||||
}
|
}
|
||||||
|
return filename.slice(dotIndex + 1)
|
||||||
}
|
}
|
||||||
interface FileTypes {
|
export const FILE_TYPES = {
|
||||||
[key: string]: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const filetypes: FileTypes = {
|
|
||||||
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
||||||
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
audio: ['mp3', 'flac', 'ogg', 'aac'],
|
||||||
pdf: ['pdf'],
|
archive: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'],
|
||||||
}
|
document: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf'],
|
||||||
|
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
|
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
|
||||||
|
print: ['epub', 'mobi', 'pdf'],
|
||||||
|
text: [
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'cfg',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
|
'py',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'jsx',
|
||||||
|
'tsx',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'sass',
|
||||||
|
'less',
|
||||||
|
'vue',
|
||||||
|
'php',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'java',
|
||||||
|
'c',
|
||||||
|
'cpp',
|
||||||
|
'h',
|
||||||
|
'hpp',
|
||||||
|
'cs',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'zsh',
|
||||||
|
'fish',
|
||||||
|
'ps1',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'sql',
|
||||||
|
'lua',
|
||||||
|
'r',
|
||||||
|
'pl',
|
||||||
|
'dockerfile',
|
||||||
|
'makefile',
|
||||||
|
'gitignore',
|
||||||
|
'gitattributes',
|
||||||
|
'env',
|
||||||
|
'diff',
|
||||||
|
'patch'
|
||||||
|
]
|
||||||
|
} as const
|
||||||
|
|
||||||
export function getFileType(name: string): string {
|
export type FileCategory = keyof typeof FILE_TYPES
|
||||||
const ext = name.split('.').pop()?.toLowerCase()
|
|
||||||
if (!ext || ext.length === name.length) return 'unknown'
|
export function getFileType(name: string): FileCategory | 'unknown' {
|
||||||
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
|
const dotIndex = name.lastIndexOf('.')
|
||||||
|
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||||
|
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||||
|
for (const category of Object.keys(FILE_TYPES) as FileCategory[]) {
|
||||||
|
if ((FILE_TYPES[category] as readonly string[]).includes(ext)) {
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prebuilt for fast & consistent sorting
|
// Prebuilt for fast & consistent sorting
|
||||||
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
export const collator = new Intl.Collator('en', {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
usage: 'search'
|
||||||
|
})
|
||||||
|
|
||||||
// Preformat document names for faster search
|
// Preformat document names for faster search
|
||||||
export function haystackFormat(str: string) {
|
export function haystackFormat(str: string) {
|
||||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
const based = str
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
return '^' + based + '$'
|
return '^' + based + '$'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Preformat search string for faster search
|
// Preformat search string for faster search
|
||||||
export function needleFormat(query: string) {
|
export function needleFormat(query: string) {
|
||||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
const based = query
|
||||||
return {based, words: based.split(/\s+/)}
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
return { based, words: based.split(/\s+/) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test if haystack includes needle
|
// Test if haystack includes needle
|
||||||
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
|
export function localeIncludes(
|
||||||
const {based, words} = filter
|
haystack: string,
|
||||||
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
|
filter: { based: string; words: string[] }
|
||||||
|
) {
|
||||||
|
const { based, words } = filter
|
||||||
|
return (
|
||||||
|
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
type ScrollOptions = {
|
||||||
|
topPad?: number
|
||||||
|
bottomPad?: number
|
||||||
|
keyboardWindowMs?: number
|
||||||
|
getScrollContainer?: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
|
||||||
|
const {
|
||||||
|
topPad = 84,
|
||||||
|
bottomPad = 84,
|
||||||
|
keyboardWindowMs = 260,
|
||||||
|
getScrollContainer = () =>
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let scrollAnimationFrame: number | null = null
|
||||||
|
let scrollTargetY: number | null = null
|
||||||
|
let scrollVelocity = 0
|
||||||
|
let keyboardFollowUntil = 0
|
||||||
|
|
||||||
|
const markKeyboardFollow = () => {
|
||||||
|
keyboardFollowUntil = performance.now() + keyboardWindowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
||||||
|
|
||||||
|
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
||||||
|
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||||
|
return Math.min(maxY, Math.max(0, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
const visibleTop = scrollerRect.top + topPad
|
||||||
|
const visibleBottom = scrollerRect.bottom - bottomPad
|
||||||
|
|
||||||
|
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
||||||
|
|
||||||
|
if (rect.top < visibleTop) {
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSmoothCursorScroll = () => {
|
||||||
|
if (scrollAnimationFrame != null) return
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
|
||||||
|
if (scrollTargetY == null) {
|
||||||
|
scrollVelocity *= 0.68
|
||||||
|
if (Math.abs(scrollVelocity) > 0.05) {
|
||||||
|
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = scroller.scrollTop
|
||||||
|
const delta = scrollTargetY - current
|
||||||
|
const absDelta = Math.abs(delta)
|
||||||
|
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
||||||
|
scroller.scrollTop = scrollTargetY
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
|
||||||
|
const damping = 0.76
|
||||||
|
scrollVelocity += delta * stiffness
|
||||||
|
scrollVelocity *= damping
|
||||||
|
|
||||||
|
const next = clampScrollY(current + scrollVelocity, scroller)
|
||||||
|
if (next === current) scrollVelocity = 0
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepVisible = (el: HTMLElement | null) => {
|
||||||
|
if (!keyboardFollowActive()) {
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = cursorScrollTarget(el)
|
||||||
|
if (target == null) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTargetY = target
|
||||||
|
runSmoothCursorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
keyboardFollowUntil = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markKeyboardFollow, keepVisible, cancel }
|
||||||
|
}
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<Gallery
|
<div class="transition-wrapper">
|
||||||
v-if="store.prefs.gallery"
|
<Transition
|
||||||
ref="fileExplorer"
|
:name="transitionName"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
@after-enter="onAfterEnter"
|
||||||
:path="props.path"
|
>
|
||||||
:documents="documents"
|
<KeepAlive>
|
||||||
/>
|
<component
|
||||||
<FileExplorer
|
:is="store.prefs.gallery ? Gallery : FileExplorer"
|
||||||
v-else
|
:key="cacheKey"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${Router.currentRoute.value.path}`"
|
class="explorer-content"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<div v-if="!store.prefs.gallery && documents.some(doc => doc.previewable)" class="suggest-gallery">
|
</KeepAlive>
|
||||||
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
|
</Transition>
|
||||||
Gallery View
|
|
||||||
</div>
|
</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import Router from '@/router/index'
|
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
|
||||||
import { sorted } from '@/utils/docsort'
|
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
import Gallery from '@/components/Gallery.vue'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { collator } from '@/utils'
|
||||||
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
|
import { computed, nextTick, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@@ -34,70 +34,122 @@ const props = defineProps<{
|
|||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
const cacheKey = computed(
|
||||||
|
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const transitionName = computed(() => {
|
||||||
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderScrollTop = new Map<string, number>()
|
||||||
|
const scrollKey = (path: string) => path || '/'
|
||||||
|
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
|
||||||
|
|
||||||
|
const restoreScroll = (path: string) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (!scroller) return
|
||||||
|
const top = folderScrollTop.get(scrollKey(path)) ?? 0
|
||||||
|
scroller.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAfterEnter = () => {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
restoreScroll(folderPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||||
|
// Skip if store.query already matches (means we triggered this via typing)
|
||||||
|
watch(
|
||||||
|
() => [props.query, props.path.join('/')] as const,
|
||||||
|
([query, loc]) => {
|
||||||
|
if (store.query === query) return // Already searching this query
|
||||||
|
store.search(query, loc)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const documents = computed(() => {
|
const documents = computed(() => {
|
||||||
const loc = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
const query = props.query
|
const query = props.query
|
||||||
// List the current location
|
|
||||||
if (!query) return sorted(
|
// List the current location (no search)
|
||||||
store.document.filter(doc => doc.loc === loc),
|
if (!query) {
|
||||||
store.prefs.sortListing,
|
// Access docVersion to make this reactive to document changes
|
||||||
)
|
void store.docVersion
|
||||||
// Find up to 100 newest documents that match the search
|
const hidden = store.hiddenPaths
|
||||||
const needle = needleFormat(query)
|
const docs = getDocuments().filter(
|
||||||
let limit = 100
|
doc =>
|
||||||
let docs = []
|
doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
|
||||||
for (const doc of store.recentDocuments) {
|
)
|
||||||
if (localeIncludes(doc.haystack, needle)) {
|
// Overlay ghosts for this location (excluding hidden ones)
|
||||||
docs.push(doc)
|
const ghosts = store.ghosts.filter(
|
||||||
if (--limit === 0) break
|
g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name)
|
||||||
}
|
)
|
||||||
|
// Merge: ghosts that don't conflict with real docs
|
||||||
|
const realNames = new Set(docs.map(d => d.name))
|
||||||
|
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
|
||||||
|
return sorted(merged, store.prefs.sortListing)
|
||||||
}
|
}
|
||||||
const locsub = loc + '/'
|
|
||||||
// Custom sort override in effect?
|
// Search results from worker (also filter hidden)
|
||||||
|
const hidden = store.hiddenPaths
|
||||||
|
const docs = store.searchResults.filter(
|
||||||
|
doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
if (order) return sorted(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
|
||||||
docs.sort((a, b) => (
|
// Results are already sorted by relevance in the worker
|
||||||
// @ts-ignore
|
|
||||||
(b.loc === loc) - (a.loc === loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
|
||||||
collator.compare(a.loc, b.loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(a.type === 'file') - (b.type === 'file') ||
|
|
||||||
// @ts-ignore
|
|
||||||
b.name.includes(query) - a.name.includes(query) ||
|
|
||||||
collator.compare(a.name, b.name)
|
|
||||||
))
|
|
||||||
return docs
|
return docs
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
store.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
store.query = props.query
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
folderPath,
|
||||||
|
async (path, oldPath) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (scroller && oldPath !== undefined) {
|
||||||
|
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => restoreScroll(path))
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
|
watch(
|
||||||
|
[() => props.path.join('/'), () => store.documentCount],
|
||||||
|
([path, len], [oldPath, oldLen]) => {
|
||||||
|
// React to path change or initial document load (0 → non-zero)
|
||||||
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.empty-container {
|
.search-loading {
|
||||||
display: flex;
|
position: fixed;
|
||||||
flex-direction: column;
|
bottom: 1rem;
|
||||||
align-items: center;
|
right: 1rem;
|
||||||
justify-content: center;
|
padding: 0.5rem 1rem;
|
||||||
height: 100%;
|
background: var(--accent-color, #007bff);
|
||||||
font-size: 2rem;
|
color: white;
|
||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
border-radius: 0.25rem;
|
||||||
color: var(--accent-color);
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.suggest-gallery p {
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.suggest-gallery {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-editor">
|
||||||
|
<div class="editor-body">
|
||||||
|
<div v-if="loading" class="status">Loading…</div>
|
||||||
|
<div v-else-if="error" class="status error">{{ error }}</div>
|
||||||
|
<div v-else ref="editorHost" class="editor-host"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
|
import { LanguageDescription } from '@codemirror/language'
|
||||||
|
import { languages } from '@codemirror/language-data'
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import { EditorView, keymap } from '@codemirror/view'
|
||||||
|
import { basicSetup } from 'codemirror'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref
|
||||||
|
} from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
||||||
|
|
||||||
|
const filePath = computed(() => {
|
||||||
|
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
|
||||||
|
return raw.replace(/^\//, '').replace(/\/$/, '')
|
||||||
|
})
|
||||||
|
const filename = computed(() => filePath.value.split('/').pop() || '')
|
||||||
|
|
||||||
|
const filesUrl = computed(() => {
|
||||||
|
return (
|
||||||
|
'/files/' +
|
||||||
|
filePath.value
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const original = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const editorHost = ref<HTMLDivElement | null>(null)
|
||||||
|
let editorView: EditorView | null = null
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
const dirty = computed(() => content.value !== original.value)
|
||||||
|
|
||||||
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!dirty.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let beforeUnloadActive = false
|
||||||
|
|
||||||
|
const activateEditorBindings = () => {
|
||||||
|
store.editorSave = save
|
||||||
|
if (!beforeUnloadActive) {
|
||||||
|
window.addEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateEditorBindings = () => {
|
||||||
|
if (store.editorSave === save) {
|
||||||
|
store.editorSave = null
|
||||||
|
}
|
||||||
|
if (beforeUnloadActive) {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectLanguage = async () => {
|
||||||
|
const language = LanguageDescription.matchFilename(languages, filename.value)
|
||||||
|
if (!language) return []
|
||||||
|
try {
|
||||||
|
return [await language.load()]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initEditor = async (text: string) => {
|
||||||
|
if (!editorHost.value) return
|
||||||
|
const languageExtensions = await detectLanguage()
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: text,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
oneDark,
|
||||||
|
languageCompartment.of(languageExtensions),
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
content.value = update.state.doc.toString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Mod-s',
|
||||||
|
run: () => {
|
||||||
|
void save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indentWithTab
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
editorView = new EditorView({ state, parent: editorHost.value })
|
||||||
|
editorView.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving.value || loading.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(filesUrl.value, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: content.value,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
original.value = content.value
|
||||||
|
store.showToast(`Saved ${filename.value}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed', err)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
activateEditorBindings()
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(filesUrl.value, { method: 'HEAD' })
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
|
const size = Number(res.headers.get('content-length') || '0')
|
||||||
|
if (size > MAX_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const textRes = await fetch(filesUrl.value)
|
||||||
|
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
|
||||||
|
const text = await textRes.text()
|
||||||
|
content.value = text
|
||||||
|
original.value = text
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
await initEditor(text)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||||
|
} finally {
|
||||||
|
if (loading.value) loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
activateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
editorView?.destroy()
|
||||||
|
editorView = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ddd;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.editor-host {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-editor) {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-scroller) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content) {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-focused .cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content ::selection) {
|
||||||
|
background: var(--soft-color, #146);
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content, .cm-gutter) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
color: #f55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Search worker - runs search in background thread
|
||||||
|
// Receives document updates and search queries, returns incremental results
|
||||||
|
|
||||||
|
interface DocData {
|
||||||
|
loc: string
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
size: number
|
||||||
|
allocated: number
|
||||||
|
mtime: number
|
||||||
|
dir: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerDoc extends DocData {
|
||||||
|
haystack: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchMessage {
|
||||||
|
type: 'search'
|
||||||
|
query: string
|
||||||
|
loc: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateMessage {
|
||||||
|
type: 'update'
|
||||||
|
documents: DocData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingMessage = SearchMessage | UpdateMessage
|
||||||
|
|
||||||
|
interface ResultMessage {
|
||||||
|
type: 'results'
|
||||||
|
docs: DocData[]
|
||||||
|
id: number
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker state
|
||||||
|
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||||
|
let currentSearchId = 0
|
||||||
|
|
||||||
|
// Search result cache - cleared when documents change
|
||||||
|
interface CacheEntry {
|
||||||
|
query: string // Normalized query string
|
||||||
|
results: WorkerDoc[] // Matched results (up to limit)
|
||||||
|
complete: boolean // True if search scanned all documents
|
||||||
|
}
|
||||||
|
const searchCache: CacheEntry[] = []
|
||||||
|
const MAX_CACHE_SIZE = 10
|
||||||
|
const RESULT_LIMIT = 100
|
||||||
|
|
||||||
|
// Normalize string for search (remove diacritics, lowercase)
|
||||||
|
// Haystack adds ^ and $ markers to allow matching start/end of name
|
||||||
|
function normalizeHaystack(str: string): string {
|
||||||
|
return (
|
||||||
|
'^' +
|
||||||
|
str
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase() +
|
||||||
|
'$'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuery(str: string): string {
|
||||||
|
return str
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if document matches search query
|
||||||
|
function matches(haystack: string, query: string, words: string[]): boolean {
|
||||||
|
return haystack.includes(query) || words.every(word => haystack.includes(word))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collator for sorting
|
||||||
|
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
|
||||||
|
|
||||||
|
// Yield control to allow new messages to be processed
|
||||||
|
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
// Find best cache entry to filter from
|
||||||
|
// Returns entry if new query's results are guaranteed to be a subset of cached results
|
||||||
|
// Only valid if the cached search was complete (scanned all documents)
|
||||||
|
function findCacheSubset(query: string): CacheEntry | null {
|
||||||
|
// Look for a cached query that the new query starts with
|
||||||
|
// e.g., cached "foo" can be used for "foobar" or "foo bar"
|
||||||
|
// The longer the prefix, the better (fewer items to filter)
|
||||||
|
// IMPORTANT: Only use complete cache entries - incomplete ones may have
|
||||||
|
// missed results that would match the more specific query
|
||||||
|
let best: CacheEntry | null = null
|
||||||
|
for (const entry of searchCache) {
|
||||||
|
if (entry.complete && query.startsWith(entry.query)) {
|
||||||
|
if (!best || entry.query.length > best.query.length) {
|
||||||
|
best = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add result to cache
|
||||||
|
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
|
||||||
|
// Remove existing entry for same query if any
|
||||||
|
const idx = searchCache.findIndex(e => e.query === query)
|
||||||
|
if (idx !== -1) searchCache.splice(idx, 1)
|
||||||
|
// Add to front (most recent)
|
||||||
|
searchCache.unshift({ query, results, complete })
|
||||||
|
// Trim cache
|
||||||
|
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache (called when documents change)
|
||||||
|
function clearCache() {
|
||||||
|
searchCache.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform search with incremental results
|
||||||
|
async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||||
|
const query = normalizeQuery(rawQuery)
|
||||||
|
const words = query.split(/\s+/)
|
||||||
|
const results: WorkerDoc[] = []
|
||||||
|
let lastResultCount = 0
|
||||||
|
|
||||||
|
// Check cache for exact match
|
||||||
|
const exactMatch = searchCache.find(e => e.query === query)
|
||||||
|
if (exactMatch) {
|
||||||
|
if (currentSearchId === searchId) {
|
||||||
|
postResults(exactMatch.results, rawQuery, loc, searchId, true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can filter from a cached superset
|
||||||
|
const cacheEntry = findCacheSubset(query)
|
||||||
|
if (cacheEntry) {
|
||||||
|
// Fast path: filter from cached results (only used for complete cache entries)
|
||||||
|
for (const doc of cacheEntry.results) {
|
||||||
|
if (matches(doc.haystack, query, words)) {
|
||||||
|
results.push(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cache entry was complete, so filtered results are also complete
|
||||||
|
addToCache(query, results, true)
|
||||||
|
|
||||||
|
if (currentSearchId === searchId) {
|
||||||
|
postResults(results, rawQuery, loc, searchId, true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: scan all documents
|
||||||
|
const batchSize = 500
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < recentDocuments.length && results.length < RESULT_LIMIT;
|
||||||
|
i += batchSize
|
||||||
|
) {
|
||||||
|
if (currentSearchId !== searchId) return // Superseded
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||||
|
for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
|
||||||
|
const doc = recentDocuments[j]!
|
||||||
|
if (matches(doc.haystack, query, words)) {
|
||||||
|
results.push(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post incremental results if we found new matches
|
||||||
|
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||||
|
lastResultCount = results.length
|
||||||
|
postResults(results, rawQuery, loc, searchId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control between batches
|
||||||
|
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
|
||||||
|
await yieldControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache and post final results
|
||||||
|
addToCache(query, results, results.length < RESULT_LIMIT)
|
||||||
|
if (currentSearchId === searchId) {
|
||||||
|
postResults(results, rawQuery, loc, searchId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post results to main thread
|
||||||
|
function postResults(
|
||||||
|
docs: WorkerDoc[],
|
||||||
|
query: string,
|
||||||
|
loc: string,
|
||||||
|
id: number,
|
||||||
|
done: boolean
|
||||||
|
) {
|
||||||
|
const sorted = sortResults(docs, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sorted.map(({ haystack, ...rest }) => rest),
|
||||||
|
id,
|
||||||
|
done
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort results by relevance
|
||||||
|
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||||
|
const locsub = loc + '/'
|
||||||
|
return [...docs].sort(
|
||||||
|
(a, b) =>
|
||||||
|
// Current folder first
|
||||||
|
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||||
|
// Then subfolders
|
||||||
|
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||||
|
// Then by location
|
||||||
|
collator.compare(a.loc, b.loc) ||
|
||||||
|
// Folders before files
|
||||||
|
Number(b.dir) - Number(a.dir) ||
|
||||||
|
// Exact name match first
|
||||||
|
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||||
|
// Finally by name
|
||||||
|
collator.compare(a.name, b.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||||
|
const msg = e.data
|
||||||
|
|
||||||
|
if (msg.type === 'update') {
|
||||||
|
// Update document list with haystacks, sorted by mtime descending
|
||||||
|
recentDocuments = msg.documents
|
||||||
|
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
clearCache()
|
||||||
|
} else if (msg.type === 'search') {
|
||||||
|
currentSearchId = msg.id
|
||||||
|
if (msg.query) {
|
||||||
|
await performSearch(msg.query, msg.loc, msg.id)
|
||||||
|
} else {
|
||||||
|
// Empty query - no results needed
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: [],
|
||||||
|
id: msg.id,
|
||||||
|
done: true
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.vitest.json"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/node18/tsconfig.json",
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
"include": [
|
"include": ["vite.config.*"],
|
||||||
"vite.config.*",
|
|
||||||
"vitest.config.*",
|
|
||||||
"cypress.config.*",
|
|
||||||
"nightwatch.conf.*",
|
|
||||||
"playwright.config.*"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.app.json",
|
|
||||||
"exclude": [],
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"types": ["node", "jsdom"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* FastAPI-Vue Vite Plugin
|
||||||
|
*
|
||||||
|
* Configures Vite for FastAPI backend integration:
|
||||||
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
*
|
||||||
|
* Environment variables (with defaults):
|
||||||
|
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
|
||||||
|
*/
|
||||||
|
|
||||||
|
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
|
||||||
|
|
||||||
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
|
// Build proxy configuration for each path
|
||||||
|
const proxy = {}
|
||||||
|
for (const path of paths) {
|
||||||
|
proxy[path] = {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: false,
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'fastapi-vite',
|
||||||
|
config: () => ({
|
||||||
|
server: { proxy },
|
||||||
|
build: {
|
||||||
|
outDir: '../cista/frontend-build',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +1,43 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { URL, fileURLToPath } from 'node:url'
|
||||||
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
|
||||||
|
|
||||||
// Development mode:
|
|
||||||
// npm run dev # Run frontend that proxies to dev_backend
|
|
||||||
// cista -l :8000 --dev # Run backend
|
|
||||||
const dev_backend = {
|
|
||||||
target: "http://localhost:8000",
|
|
||||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
|
||||||
ws: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
|
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
|
||||||
vue(),
|
vue(),
|
||||||
svgLoader(), // import svg files
|
svgLoader(), // import svg files
|
||||||
Components(), // auto import components
|
Components() // auto import components
|
||||||
],
|
],
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
less: {
|
less: {
|
||||||
modifyVars: {},
|
modifyVars: {},
|
||||||
javascriptEnabled: true,
|
javascriptEnabled: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": dev_backend,
|
|
||||||
"/files": dev_backend,
|
|
||||||
"/login": dev_backend,
|
|
||||||
"/logout": dev_backend,
|
|
||||||
"/password-change": dev_backend,
|
|
||||||
"/zip": dev_backend,
|
|
||||||
"/preview": dev_backend,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
outDir: "../cista/wwwroot",
|
rollupOptions: {
|
||||||
emptyOutDir: true,
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
// Bundle all SVG icons into a single chunk
|
||||||
|
icons: ['/src/assets/svg/index.ts']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,26 +10,44 @@ readme = "README.md"
|
|||||||
authors = [
|
authors = [
|
||||||
{ name = "Vasanko" },
|
{ name = "Vasanko" },
|
||||||
]
|
]
|
||||||
|
maintainers = [
|
||||||
|
{ name = "Vasanko" },
|
||||||
|
]
|
||||||
|
keywords = ["file-server", "web-interface", "dropbox", "storage"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Environment :: Web Environment",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"License :: Public Domain",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2-cffi",
|
"argon2-cffi>=25.1.0",
|
||||||
"blake3",
|
"av>=15.0.0",
|
||||||
"brotli",
|
"blake3>=1.0.5",
|
||||||
"docopt",
|
"docopt-ng>=0.9.0",
|
||||||
"inotify",
|
"fastapi-vue>=0.5.2",
|
||||||
"msgspec",
|
"fastapi[standard]>=0.128.0",
|
||||||
"natsort",
|
"html5tagger>=1.3.0",
|
||||||
"pathvalidate",
|
"httpx>=0.28.0",
|
||||||
"pillow",
|
"inotify>=0.2.12",
|
||||||
"pyav",
|
"msgspec>=0.19.0",
|
||||||
"pyjwt",
|
"natsort>=8.4.0",
|
||||||
"pymupdf",
|
"numpy>=2.3.2",
|
||||||
"sanic",
|
"pathvalidate>=3.3.1",
|
||||||
"setproctitle",
|
"pillow>=11.3.0",
|
||||||
"stream-zip",
|
"pillow-heif>=1.1.0",
|
||||||
"tomli_w",
|
"pyjwt>=2.10.1",
|
||||||
|
"pymupdf>=1.26.3",
|
||||||
|
"pyvips[binary]>=3.1.1",
|
||||||
|
"sanic>=25.12.0",
|
||||||
|
"setproctitle>=1.3.6",
|
||||||
|
"stream-zip>=0.0.83",
|
||||||
|
"tomli_w>=1.2.0",
|
||||||
|
"tracerite>=2.3.1",
|
||||||
|
"zstandard>=0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -40,66 +58,147 @@ cista = "cista.__main__:main"
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest",
|
"pytest>=8.4.1",
|
||||||
"ruff",
|
"ruff>=0.8.0",
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"pre-commit>=4.0.0",
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.4.1",
|
||||||
|
"pytest-cov>=6.0.0",
|
||||||
|
"pytest-asyncio>=0.25.0",
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
"sphinx>=8.0.0",
|
||||||
|
"sphinx-rtd-theme>=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["cista/wwwroot"]
|
artifacts = ["cista/frontend-build", "cista/docker"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
targets.sdist.include = [
|
||||||
|
"/cista",
|
||||||
|
]
|
||||||
hooks.vcs.version-file = "cista/_version.py"
|
hooks.vcs.version-file = "cista/_version.py"
|
||||||
hooks.vcs.template = """
|
hooks.vcs.template = """
|
||||||
# This file is automatically generated by hatch build.
|
# This file is automatically generated by hatch build.
|
||||||
__version__ = {version!r}
|
__version__ = {version!r}
|
||||||
"""
|
"""
|
||||||
only-packages = true
|
only-packages = true
|
||||||
targets.sdist.include = [
|
packages = ["cista"]
|
||||||
"/cista",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = [
|
addopts = [
|
||||||
"--import-mode=importlib",
|
"--import-mode=importlib",
|
||||||
"--verbosity=-1",
|
"--verbosity=2",
|
||||||
"-p no:warnings",
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"--cov=cista",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-branch",
|
||||||
]
|
]
|
||||||
testpaths = [
|
testpaths = ["tests"]
|
||||||
"tests",
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
markers = [
|
||||||
|
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||||
|
"integration: marks tests as integration tests",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"error",
|
||||||
|
"ignore::UserWarning",
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
ignore = [
|
ignore = [
|
||||||
"A0",
|
"COM812", # formatter compatibility
|
||||||
"ARG001",
|
"ISC001", # formatter compatibility
|
||||||
"ANN",
|
"ANN001", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"B018",
|
"ANN002", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"BLE001",
|
"ANN003", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"C901",
|
"ANN201", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"COM812", # conflicts with ruff format
|
"ANN202", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"D",
|
"ANN204", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"E501",
|
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"EM1",
|
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||||
"FIX002",
|
"C901", # legacy complexity; keep other correctness rules enabled
|
||||||
"ISC001", # conflicts with ruff format
|
"CPY", # copyright notices not wanted in this codebase
|
||||||
"PGH003",
|
"D100", # legacy docs not yet standardized
|
||||||
"PLR0912",
|
"D101", # legacy docs not yet standardized
|
||||||
"PLR2004",
|
"D102", # legacy docs not yet standardized
|
||||||
"PLW0603",
|
"D103", # legacy docs not yet standardized
|
||||||
"S101",
|
"D104", # legacy docs not yet standardized
|
||||||
"SLF001",
|
"D105", # legacy docs not yet standardized
|
||||||
"T201",
|
"D107", # legacy docs not yet standardized
|
||||||
"TD0",
|
"D200", # legacy docs not yet standardized
|
||||||
"TRY",
|
"D203", # avoid D203/D211 conflict under ALL selection
|
||||||
|
"D212", # legacy docs not yet standardized
|
||||||
|
"D213", # legacy docs not yet standardized
|
||||||
|
"D400", # legacy docs not yet standardized
|
||||||
|
"D401", # legacy docs not yet standardized
|
||||||
|
"D413", # legacy docs not yet standardized
|
||||||
|
"D415", # legacy docs not yet standardized
|
||||||
|
"E501", # existing long literals/log strings
|
||||||
|
"EM101", # exception-message style; low signal for this project
|
||||||
|
"EM102", # exception-message style; low signal for this project
|
||||||
|
"INP001", # scripts folder intentionally lacks package markers
|
||||||
|
"PLR0911", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0912", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0913", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0915", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR2004", # we like magic numbers (don't remove this suppression)
|
||||||
|
"PLW0603", # module-level shared state exists in server runtime code
|
||||||
|
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||||
]
|
]
|
||||||
show-source = true
|
isort.known-first-party = ["cista"]
|
||||||
show-fixes = true
|
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
|
||||||
|
per-file-ignores."scripts/*" = ["T20"]
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[dependency-groups]
|
||||||
known-first-party = ["cista"]
|
dev = [
|
||||||
|
"pytest>=8.4.1",
|
||||||
|
"pytest-asyncio>=0.25.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"pre-commit>=4.0.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"sanic-testing>=24.6.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff.per-file-ignores]
|
[tool.coverage.run]
|
||||||
"tests/*" = ["S", "ANN", "D", "INP"]
|
source = ["cista"]
|
||||||
|
branch = true
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/test_*",
|
||||||
|
"*/__pycache__/*",
|
||||||
|
"cista/_version.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"if self.debug:",
|
||||||
|
"if settings.DEBUG",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if 0:",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"class .*\\bProtocol\\):",
|
||||||
|
"@(abc\\.)?abstractmethod",
|
||||||
|
]
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
precision = 2
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Benchmark OnlyOffice output formats for office document preview.
|
||||||
|
|
||||||
|
Compares:
|
||||||
|
1. BMP → AVIF (via pyvips)
|
||||||
|
2. PNG → AVIF (via pyvips)
|
||||||
|
3. PNG only (no AVIF compression)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/benchmark_onlyoffice_formats.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import pyvips
|
||||||
|
|
||||||
|
os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ONLYOFFICE_URL = os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
|
||||||
|
CALLBACK_HOST = os.environ.get("ONLYOFFICE_CALLBACK_HOST", "")
|
||||||
|
|
||||||
|
AVIF_QUALITY = 60
|
||||||
|
AVIF_MAXSIZE = 1024
|
||||||
|
|
||||||
|
# Directories to scan
|
||||||
|
SCAN_DIRS = [
|
||||||
|
Path("/mnt/c/Users/User/Downloads/DocsMisc"),
|
||||||
|
Path(
|
||||||
|
"/mnt/c/Users/User/Downloads/Lattialämmityksen säätöarvot As Oy Helsingin Pulteri D ja E.etc"
|
||||||
|
),
|
||||||
|
Path("/mnt/c/Users/User/Downloads/As. Oy Aidasmäentie 16-18 teholaskenta.etc"),
|
||||||
|
]
|
||||||
|
|
||||||
|
OFFICE_EXTS = {
|
||||||
|
".doc",
|
||||||
|
".dot",
|
||||||
|
".docx",
|
||||||
|
".docm",
|
||||||
|
".dotx",
|
||||||
|
".dotm",
|
||||||
|
".rtf",
|
||||||
|
".odt",
|
||||||
|
".ott",
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".mhtml",
|
||||||
|
".mht",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".xml",
|
||||||
|
".wps",
|
||||||
|
".wri",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".xlsm",
|
||||||
|
".xlsb",
|
||||||
|
".xltx",
|
||||||
|
".xltm",
|
||||||
|
".ods",
|
||||||
|
".ots",
|
||||||
|
".csv",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".pptm",
|
||||||
|
".pps",
|
||||||
|
".ppsx",
|
||||||
|
".pot",
|
||||||
|
".potx",
|
||||||
|
".odp",
|
||||||
|
".otp",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OnlyOffice client (inline to avoid import overhead)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("0.0.0.0", 0)) # noqa: S104
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_callback_host() -> str:
|
||||||
|
if CALLBACK_HOST:
|
||||||
|
return CALLBACK_HOST
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "inet " in line:
|
||||||
|
parts = line.strip().split()
|
||||||
|
return parts[1].split("/")[0]
|
||||||
|
except Exception:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_file_temporarily(file_path: Path):
|
||||||
|
directory = str(file_path.parent)
|
||||||
|
filename = file_path.name
|
||||||
|
port = _get_free_port()
|
||||||
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
|
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
host = _get_callback_host()
|
||||||
|
url = f"http://{host}:{port}/{quote(filename)}"
|
||||||
|
return url, httpd
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_convert(
|
||||||
|
file_path: Path, output_type: str, timeout: float = 60.0
|
||||||
|
) -> bytes:
|
||||||
|
oo_url = ONLYOFFICE_URL.rstrip("/")
|
||||||
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
|
doc_url, httpd = _serve_file_temporarily(file_path)
|
||||||
|
try:
|
||||||
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
|
payload = {
|
||||||
|
"async": False,
|
||||||
|
"filetype": suffix,
|
||||||
|
"key": f"bench_{file_path.stat().st_mtime_ns}_{output_type}",
|
||||||
|
"outputtype": output_type,
|
||||||
|
"title": file_path.name,
|
||||||
|
"url": doc_url,
|
||||||
|
}
|
||||||
|
req = urllib.request.Request( # noqa: S310
|
||||||
|
convert_url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||||
|
body = resp.read()
|
||||||
|
text = body.decode("utf-8", errors="replace")
|
||||||
|
if "<Error>" in text:
|
||||||
|
code = text.split("<Error>")[1].split("</Error>")[0]
|
||||||
|
raise RuntimeError(f"OnlyOffice error {code}")
|
||||||
|
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
||||||
|
file_url = file_url.replace("&", "&")
|
||||||
|
with urllib.request.urlopen(file_url, timeout=timeout) as img_resp: # noqa: S310
|
||||||
|
return img_resp.read()
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Benchmark helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
name: str
|
||||||
|
ext: str
|
||||||
|
oo_time: float
|
||||||
|
avif_time: float = 0.0
|
||||||
|
raw_size: int = 0
|
||||||
|
final_size: int = 0
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def avif_from_buffer(
|
||||||
|
img_bytes: bytes, quality: int = AVIF_QUALITY, maxsize: int = AVIF_MAXSIZE
|
||||||
|
) -> tuple[float, bytes]:
|
||||||
|
t0 = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_buffer(img_bytes, "")
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
buf = img.write_to_buffer(".avif", Q=quality, effort=0, strip=True)
|
||||||
|
t1 = perf_counter()
|
||||||
|
return t1 - t0, buf
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_file(path: Path) -> list[Result]:
|
||||||
|
results: list[Result] = []
|
||||||
|
|
||||||
|
# 1. BMP → AVIF
|
||||||
|
try:
|
||||||
|
t0 = perf_counter()
|
||||||
|
bmp = onlyoffice_convert(path, "bmp")
|
||||||
|
t1 = perf_counter()
|
||||||
|
avif_t, avif_buf = avif_from_buffer(bmp)
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name,
|
||||||
|
ext=path.suffix.lower(),
|
||||||
|
oo_time=t1 - t0,
|
||||||
|
avif_time=avif_t,
|
||||||
|
raw_size=len(bmp),
|
||||||
|
final_size=len(avif_buf),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"bmp: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. PNG → AVIF
|
||||||
|
try:
|
||||||
|
t0 = perf_counter()
|
||||||
|
png = onlyoffice_convert(path, "png")
|
||||||
|
t1 = perf_counter()
|
||||||
|
avif_t, avif_buf = avif_from_buffer(png)
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name,
|
||||||
|
ext=path.suffix.lower(),
|
||||||
|
oo_time=t1 - t0,
|
||||||
|
avif_time=avif_t,
|
||||||
|
raw_size=len(png),
|
||||||
|
final_size=len(avif_buf),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name, ext=path.suffix.lower(), oo_time=0, error=f"png: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. PNG only
|
||||||
|
try:
|
||||||
|
t0 = perf_counter()
|
||||||
|
png = onlyoffice_convert(path, "png")
|
||||||
|
t1 = perf_counter()
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name,
|
||||||
|
ext=path.suffix.lower(),
|
||||||
|
oo_time=t1 - t0,
|
||||||
|
avif_time=0.0,
|
||||||
|
raw_size=len(png),
|
||||||
|
final_size=len(png),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(
|
||||||
|
Result(
|
||||||
|
name=path.name,
|
||||||
|
ext=path.suffix.lower(),
|
||||||
|
oo_time=0,
|
||||||
|
error=f"png-only: {e}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
docs: list[Path] = []
|
||||||
|
for d in SCAN_DIRS:
|
||||||
|
if not d.exists():
|
||||||
|
continue
|
||||||
|
docs.extend(p for p in d.iterdir() if p.suffix.lower() in OFFICE_EXTS)
|
||||||
|
docs.sort()
|
||||||
|
|
||||||
|
total = len(docs)
|
||||||
|
print(f"Benchmarking {total} documents against OnlyOffice ({ONLYOFFICE_URL})...\n")
|
||||||
|
|
||||||
|
all_results: dict[str, list[Result]] = {
|
||||||
|
"bmp→avif": [],
|
||||||
|
"png→avif": [],
|
||||||
|
"png-only": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, doc in enumerate(docs, 1):
|
||||||
|
print(f"[{i}/{total}] {doc.name} ...", end=" ", flush=True)
|
||||||
|
res = benchmark_file(doc)
|
||||||
|
for r, key in zip(res, all_results.keys(), strict=False):
|
||||||
|
all_results[key].append(r)
|
||||||
|
if r.error:
|
||||||
|
print(f"{key} ERR", end=" ")
|
||||||
|
else:
|
||||||
|
print(f"{key} OK", end=" ")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(
|
||||||
|
f"{'Format':<12} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10} {'Ratio':>8}"
|
||||||
|
)
|
||||||
|
print("-" * 100)
|
||||||
|
|
||||||
|
for key, results in all_results.items():
|
||||||
|
ok = [r for r in results if not r.error]
|
||||||
|
errs = [r for r in results if r.error]
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
avg_oo = sum(r.oo_time for r in ok) / len(ok) * 1000
|
||||||
|
avg_avif = sum(r.avif_time for r in ok) / len(ok) * 1000
|
||||||
|
avg_total = avg_oo + avg_avif
|
||||||
|
avg_raw = sum(r.raw_size for r in ok) / len(ok) / 1024
|
||||||
|
avg_final = sum(r.final_size for r in ok) / len(ok) / 1024
|
||||||
|
ratio = avg_raw / avg_final if avg_final else 0
|
||||||
|
print(
|
||||||
|
f"{key:<12} {len(ok):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_total:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f} {ratio:>8.1f}x"
|
||||||
|
)
|
||||||
|
for r in errs[:3]:
|
||||||
|
print(f" ERROR: {r.name}: {r.error}")
|
||||||
|
|
||||||
|
# Per-extension breakdown
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("Per-extension summary (png→avif)")
|
||||||
|
print(
|
||||||
|
f"{'Ext':<8} {'Count':>6} {'OO ms':>10} {'AVIF ms':>10} {'Total ms':>10} {'Raw KB':>10} {'Final KB':>10}"
|
||||||
|
)
|
||||||
|
print("-" * 100)
|
||||||
|
|
||||||
|
by_ext: dict[str, list[Result]] = defaultdict(list)
|
||||||
|
for r in all_results["png→avif"]:
|
||||||
|
by_ext[r.ext].append(r)
|
||||||
|
|
||||||
|
for ext in sorted(by_ext.keys()):
|
||||||
|
results = [r for r in by_ext[ext] if not r.error]
|
||||||
|
if not results:
|
||||||
|
continue
|
||||||
|
avg_oo = sum(r.oo_time for r in results) / len(results) * 1000
|
||||||
|
avg_avif = sum(r.avif_time for r in results) / len(results) * 1000
|
||||||
|
avg_raw = sum(r.raw_size for r in results) / len(results) / 1024
|
||||||
|
avg_final = sum(r.final_size for r in results) / len(results) / 1024
|
||||||
|
print(
|
||||||
|
f"{ext:<8} {len(results):>6} {avg_oo:>10.1f} {avg_avif:>10.1f} {avg_oo + avg_avif:>10.1f} {avg_raw:>10.1f} {avg_final:>10.1f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# noqa: INP001
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from sys import stderr
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data):
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
stderr.write(">>> Building Cista frontend\n")
|
|
||||||
npm = shutil.which("npm")
|
|
||||||
if npm is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"NodeJS `npm` is required for building Cista but it was not found"
|
|
||||||
)
|
|
||||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
|
||||||
os.chdir("frontend")
|
|
||||||
try:
|
|
||||||
stderr.write("### npm install\n")
|
|
||||||
subprocess.run([npm, "install"], check=True) # noqa: S603
|
|
||||||
stderr.write("\n### npm run build\n")
|
|
||||||
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
|
||||||
finally:
|
|
||||||
os.chdir("..")
|
|
||||||