Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,6 +1,7 @@
|
||||
.*
|
||||
*.lock
|
||||
!.gitignore
|
||||
!.pre-commit-config.yaml
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
|
||||
@@ -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
|
||||
@@ -1,156 +1,199 @@
|
||||
# Cista Web Storage
|
||||
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||
|
||||
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
|
||||
|
||||
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||
|
||||
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
||||
|
||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
||||
|
||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||
|
||||
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||
|
||||
|
||||
## Getting Started
|
||||
### Running the Server
|
||||
|
||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||
|
||||
Create an account: (otherwise the server is public for all)
|
||||
```fish
|
||||
uvx cista --user yourname --privileged
|
||||
```
|
||||
|
||||
Serve your files at http://localhost:8000:
|
||||
```fish
|
||||
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.
|
||||
|
||||
## Authentication
|
||||
|
||||
Cista supports three authentication modes:
|
||||
|
||||
### Built-in Authentication (default)
|
||||
|
||||
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||
|
||||
```fish
|
||||
uvx cista --user admin --privileged # Create admin user
|
||||
uvx cista --user guest # Create regular user
|
||||
```
|
||||
|
||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||
|
||||
### Public Mode
|
||||
|
||||
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||
|
||||
### Paskia SSO Authentication
|
||||
|
||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
||||
|
||||
```fish
|
||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||
```
|
||||
|
||||
In Paskia mode:
|
||||
- All `/auth/*` requests are proxied to the Paskia backend
|
||||
- Users with `cista:login` permission can access files
|
||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
||||
|
||||
### Internet Access
|
||||
|
||||
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
cista.example.com {
|
||||
reverse_proxy :8000
|
||||
}
|
||||
```
|
||||
|
||||
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
|
||||
|
||||
## System Deployment
|
||||
|
||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||
|
||||
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
|
||||
|
||||
Create `/etc/systemd/system/cista@.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
|
||||
|
||||
```fish
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
||||
|
||||
Make sure you have git, uv and bun (or npm) installed.
|
||||
|
||||
Backend (Python) – setup and run:
|
||||
|
||||
```fish
|
||||
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
||||
cd cista-storage
|
||||
uv sync --dev
|
||||
uv run cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
Frontend (Vue/Vite) – run the dev server in another terminal:
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Building the package for release (frontend + Python wheel/sdist):
|
||||
|
||||
```fish
|
||||
uv build
|
||||
```
|
||||
|
||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||
# Cista Web Storage
|
||||
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||
|
||||
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
|
||||
|
||||
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||
|
||||
**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.
|
||||
|
||||
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||
|
||||
|
||||
## Getting Started
|
||||
### Running the Server
|
||||
|
||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||
|
||||
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||
```fish
|
||||
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:
|
||||
```fish
|
||||
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.
|
||||
|
||||
## Authentication
|
||||
|
||||
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
|
||||
uvx cista --user admin --privileged # Create admin user
|
||||
uvx cista --user guest # Create regular user
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
```Caddyfile
|
||||
cista.example.com {
|
||||
reverse_proxy :8000
|
||||
}
|
||||
```
|
||||
|
||||
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
|
||||
|
||||
## System Deployment
|
||||
|
||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||
|
||||
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or javascript runtimes.
|
||||
|
||||
Create (edit) a systemd unit:
|
||||
|
||||
```fish
|
||||
sudo systemctl edit --force --full cista@.service
|
||||
```
|
||||
|
||||
Paste the following:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||
Restart=always
|
||||
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
|
||||
|
||||
```fish
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
||||
|
||||
Make sure you have git, uv and bun (or npm) installed.
|
||||
|
||||
Backend (Python) – setup and run:
|
||||
|
||||
```fish
|
||||
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
||||
cd cista-storage
|
||||
uv sync --dev
|
||||
uv run cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
Frontend (Vue/Vite) – run the dev server in another terminal:
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Building the package for release (frontend + Python wheel/sdist):
|
||||
|
||||
```fish
|
||||
uv build
|
||||
```
|
||||
|
||||
Vue is used to build files in `cista/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,3 +1 @@
|
||||
from cista._version import __version__
|
||||
|
||||
__version__ # Public API
|
||||
from cista._version import __version__ as __version__
|
||||
|
||||
+14
-7
@@ -25,14 +25,19 @@ def create_banner():
|
||||
"""
|
||||
|
||||
|
||||
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
||||
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 if unix else url
|
||||
location = f"{folder} @ {listen}"
|
||||
lines = [title, location]
|
||||
if paskia_url:
|
||||
lines.append(f"Paskia: {paskia_url}")
|
||||
# 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")
|
||||
|
||||
@@ -41,8 +46,7 @@ def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
||||
|
||||
# Build the box
|
||||
box = [f"╭{'─' * inner_width}╮"]
|
||||
for line in lines:
|
||||
box.append(f"│ {line:<{inner_width - 1}}│")
|
||||
box.extend(f"│ {line:<{inner_width - 1}}│" for line in lines)
|
||||
box.append(f"╰{'─' * inner_width}╯")
|
||||
return "\n".join(box) + "\n"
|
||||
|
||||
@@ -53,10 +57,12 @@ doc = """\
|
||||
Usage:
|
||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||
cista --version
|
||||
|
||||
Options:
|
||||
-c CONFDIR Custom config directory
|
||||
-l LISTEN-ADDR Listen on
|
||||
-l, --listen LISTEN-ADDR
|
||||
Listen on
|
||||
:8989 (localhost port, plain http)
|
||||
<addr>:3000 (bind another address, port)
|
||||
/path/to/unix.sock (unix socket)
|
||||
@@ -109,7 +115,7 @@ def _main():
|
||||
args = docopt(doc)
|
||||
if args["--user"]:
|
||||
return _user(args)
|
||||
listen = args["-l"]
|
||||
listen = args["--listen"]
|
||||
# Validate arguments first
|
||||
if args["<path>"]:
|
||||
path = Path(args["<path>"]).resolve()
|
||||
@@ -157,6 +163,7 @@ def _main():
|
||||
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
|
||||
|
||||
+74
-79
@@ -1,16 +1,20 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from pathlib import PurePosixPath
|
||||
from secrets import token_bytes
|
||||
|
||||
import msgspec
|
||||
from sanic import Blueprint, json
|
||||
from sanic.exceptions import BadRequest
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import __version__, auth, config, sso, watching
|
||||
from cista import __version__, auth, config, 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.protocol import ControlTypes, FileRange, StatusMsg
|
||||
from cista.util.apphelpers import asend, websocket_wrapper
|
||||
from cista.util.apphelpers import websocket_wrapper
|
||||
|
||||
bp = Blueprint("api", url_prefix="/api")
|
||||
fileserver = FileServer()
|
||||
@@ -26,76 +30,6 @@ async def stop_fileserver(app):
|
||||
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}")
|
||||
# Signal the watcher about the uploaded file and its parent directories
|
||||
path = PurePosixPath(req.name)
|
||||
watching.notify_change(path, *path.parents)
|
||||
# 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)
|
||||
# Signal the watcher about affected paths
|
||||
watching.notify_change(*cmd.affected_paths())
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
|
||||
|
||||
@bp.websocket("watch")
|
||||
@websocket_wrapper
|
||||
async def watch(req, ws):
|
||||
@@ -105,8 +39,8 @@ async def watch(req, ws):
|
||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||
try:
|
||||
await sso.validate_sso_request(req)
|
||||
except Exception:
|
||||
pass # Ignore auth errors, user_info stays None
|
||||
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", [])
|
||||
@@ -135,15 +69,27 @@ async def watch(req, ws):
|
||||
).decode()
|
||||
)
|
||||
uuid = token_bytes(16)
|
||||
share_token = auth.request_share_token(req)
|
||||
try:
|
||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||
)
|
||||
await ws.send(space)
|
||||
await ws.send(root)
|
||||
if share_token is None:
|
||||
await ws.send(root)
|
||||
else:
|
||||
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
|
||||
# Send updates
|
||||
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
|
||||
@@ -163,6 +109,17 @@ def subscribe(uuid, ws):
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -176,3 +133,41 @@ async def update_public(request):
|
||||
raise BadRequest(str(e)) from None
|
||||
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"]
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("name must be a string")
|
||||
except KeyError:
|
||||
raise BadRequest("Missing name field") from None
|
||||
except ValueError as e:
|
||||
raise BadRequest(str(e)) from None
|
||||
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)
|
||||
|
||||
+126
-78
@@ -1,76 +1,51 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from stat import S_IFDIR, S_IFREG
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import sanic.helpers
|
||||
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.log import logger
|
||||
from setproctitle import setproctitle
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from cista import auth, config, preview, session, sso, watching
|
||||
from cista import auth, config, fileserver, preview, session, sharefs, sso, watching
|
||||
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
|
||||
|
||||
# Workaround until Sanic PR #2824 is merged
|
||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||
configure_access_logging()
|
||||
|
||||
app = Sanic("cista", strict_slashes=True)
|
||||
# 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.exception(Exception)(handle_sanic_exception)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
|
||||
|
||||
setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app):
|
||||
config.load_config()
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
# Small pool for memory-intensive preview generation
|
||||
preview_workers = max(2, min(8, cpu_count()))
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=preview_workers, thread_name_prefix="cista-preview"
|
||||
)
|
||||
# Larger pool for long-running but low-memory zip operations
|
||||
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||
watching.start(app)
|
||||
|
||||
|
||||
# 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):
|
||||
watching.stop(app)
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
logger.debug("Cista worker threads all finished")
|
||||
configure_main_logging()
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx.session = session.get(req)
|
||||
try:
|
||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||
req.ctx.user = config.config.users[req.ctx.username]
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
req.ctx.username = None
|
||||
req.ctx.user = None
|
||||
req.ctx._log_start = time.perf_counter()
|
||||
req.ctx._auth_flow = ["session: start"]
|
||||
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||
# CSRF protection
|
||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||
return # Ordinary GET requests are fine
|
||||
@@ -81,6 +56,29 @@ async def use_session(req):
|
||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def log_access(req, res):
|
||||
"""Log HTTP access in a clean single-line format."""
|
||||
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||
return res
|
||||
start = getattr(req.ctx, "_log_start", None)
|
||||
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||
client = req.client_ip or "-"
|
||||
host = req.host or "-"
|
||||
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, extra=extra
|
||||
)
|
||||
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."""
|
||||
@@ -89,23 +87,54 @@ async def forward_sso_cookies(req, res):
|
||||
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
|
||||
def http_fileserver(app):
|
||||
bp = Blueprint("fileserver")
|
||||
|
||||
@bp.on_request
|
||||
async def verify_fileserver(request):
|
||||
"""Verify access to file server routes."""
|
||||
await auth.verify(request)
|
||||
|
||||
bp.static(
|
||||
"/files/",
|
||||
config.config.path,
|
||||
use_content_range=True,
|
||||
stream_large_files=True,
|
||||
directory_view=True,
|
||||
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"
|
||||
)
|
||||
app.blueprint(bp)
|
||||
# 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)
|
||||
|
||||
|
||||
# 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):
|
||||
watching.stop(app)
|
||||
await shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
www = {}
|
||||
@@ -211,25 +240,43 @@ async def favicon(req):
|
||||
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()
|
||||
idx = 0
|
||||
ret = []
|
||||
level: int | None = None
|
||||
parent: PurePosixPath | None = None
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
while idx < len(root):
|
||||
f = root[idx]
|
||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||
if parent is not None and f.level <= level:
|
||||
level = parent = None
|
||||
if f.key in wanted:
|
||||
level, parent = f.level, loc.parent
|
||||
if parent is not None:
|
||||
wanted.discard(f.key)
|
||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||
idx += 1
|
||||
token = auth.request_share_token(req)
|
||||
|
||||
if token is None:
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
while idx < len(root):
|
||||
f = root[idx]
|
||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||
if parent is not None and f.level <= level:
|
||||
level = parent = None
|
||||
if f.key in wanted:
|
||||
level, parent = f.level, loc.parent
|
||||
if parent is not None:
|
||||
wanted.discard(f.key)
|
||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||
idx += 1
|
||||
return ret
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -239,7 +286,7 @@ async def zip_download(req, keys, zipfile, ext):
|
||||
await auth.verify(req)
|
||||
|
||||
wanted = set(keys.split("+"))
|
||||
files = get_files(wanted)
|
||||
files = get_files(req, wanted)
|
||||
|
||||
if not files:
|
||||
raise NotFound(
|
||||
@@ -265,7 +312,8 @@ async def zip_download(req, keys, zipfile, ext):
|
||||
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
||||
size -= len(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
|
||||
|
||||
|
||||
+1068
-30
File diff suppressed because it is too large
Load Diff
+42
-3
@@ -3,12 +3,13 @@ from __future__ import annotations
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from pathlib import Path, PurePath
|
||||
from time import sleep, time
|
||||
from typing import Callable, Concatenate, Literal, ParamSpec
|
||||
from typing import Concatenate, Literal, ParamSpec
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
@@ -22,6 +23,7 @@ class Config(msgspec.Struct):
|
||||
name: str = ""
|
||||
users: dict[str, User] = {}
|
||||
links: dict[str, Link] = {}
|
||||
tokens: dict[str, Token] = {}
|
||||
|
||||
|
||||
# Typing: arguments for config-modifying functions
|
||||
@@ -43,6 +45,17 @@ class Link(msgspec.Struct, omit_defaults=True):
|
||||
expires: int = 0
|
||||
|
||||
|
||||
class Token(msgspec.Struct, omit_defaults=True):
|
||||
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] = []
|
||||
|
||||
|
||||
# Global variables - initialized during application startup
|
||||
config: Config
|
||||
conffile: Path
|
||||
@@ -63,7 +76,7 @@ def init_confdir() -> None:
|
||||
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"""
|
||||
# Each part is made the same length by hashing first
|
||||
combined = b"".join(
|
||||
@@ -71,7 +84,7 @@ def derived_secret(*params, len=8) -> bytes:
|
||||
for p in [config.secret, *params]
|
||||
)
|
||||
# Output a bytes of the desired length
|
||||
return sha256(combined).digest()[:len]
|
||||
return sha256(combined).digest()[:size]
|
||||
|
||||
|
||||
def enc_hook(obj):
|
||||
@@ -204,3 +217,29 @@ def del_user(conf: Config, name: str) -> Config:
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
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)
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
|
||||
for listener in cf["listeners"]:
|
||||
try:
|
||||
if listener["protocol"] == "https":
|
||||
# TODO: Add support for TLS
|
||||
# TLS listeners are currently ignored here.
|
||||
continue
|
||||
socket = listener.get("socket")
|
||||
if socket:
|
||||
|
||||
+33
-36
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from cista import config
|
||||
from cista.util import filename
|
||||
from cista.util.asynclink import AsyncLink
|
||||
from cista.util.lrucache import LRUCache
|
||||
|
||||
|
||||
@@ -32,20 +32,23 @@ class File:
|
||||
if not self.writable:
|
||||
# Create/open file
|
||||
self.open_rw()
|
||||
assert self.fd is not None
|
||||
if self.fd is None:
|
||||
raise RuntimeError("file descriptor is not available for write")
|
||||
if file_size is not None:
|
||||
assert pos + len(buffer) <= file_size
|
||||
if pos + len(buffer) > file_size:
|
||||
raise ValueError("write exceeds declared file size")
|
||||
os.ftruncate(self.fd, file_size)
|
||||
if buffer:
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
|
||||
def __getitem__(self, slice):
|
||||
def __getitem__(self, slc):
|
||||
if self.fd is None:
|
||||
self.open_ro()
|
||||
assert self.fd is not None
|
||||
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
||||
size = slice.stop - slice.start
|
||||
if self.fd is None:
|
||||
raise RuntimeError("file descriptor is not available for read")
|
||||
os.lseek(self.fd, slc.start, os.SEEK_SET)
|
||||
size = slc.stop - slc.start
|
||||
data = os.read(self.fd, size)
|
||||
if len(data) < size:
|
||||
raise EOFError("Error reading requested range")
|
||||
@@ -62,38 +65,32 @@ class File:
|
||||
|
||||
class FileServer:
|
||||
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_lock = threading.Lock()
|
||||
self.file_locks: dict[str, threading.Lock] = {}
|
||||
|
||||
async def stop(self):
|
||||
await self.alink.stop()
|
||||
await self.worker
|
||||
self.cache.close()
|
||||
|
||||
def worker_thread(self, slink):
|
||||
@staticmethod
|
||||
def _stat_size(path):
|
||||
try:
|
||||
for req in slink:
|
||||
with req as (command, *args):
|
||||
if command == "upload":
|
||||
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()
|
||||
return Path(path).stat().st_size
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def upload(self, name, pos, data, file_size):
|
||||
def upload_info(self, name, pos, data, file_size):
|
||||
name = filename.sanitize(name)
|
||||
f = self.cache[name]
|
||||
f.write(pos, data, file_size=file_size)
|
||||
return len(data)
|
||||
|
||||
def download(self, name, start, end):
|
||||
name = filename.sanitize(name)
|
||||
f = self.cache[name]
|
||||
return f[start:end]
|
||||
with self.cache_lock:
|
||||
f = self.cache[name]
|
||||
lock = self.file_locks.setdefault(name, threading.Lock())
|
||||
with lock:
|
||||
size_before = self._stat_size(f.path)
|
||||
f.write(pos, data, file_size=file_size)
|
||||
size_after = self._stat_size(f.path)
|
||||
return {
|
||||
"written": len(data),
|
||||
"created": size_before is None,
|
||||
"size_before": size_before,
|
||||
"size_after": size_after,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
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
|
||||
|
||||
from cista import auth, config, sharefs, watching
|
||||
from cista.api import fileserver
|
||||
from cista.util import filename
|
||||
|
||||
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()
|
||||
upload_info = await asyncio.to_thread(
|
||||
fileserver.upload_info,
|
||||
rel_name,
|
||||
start,
|
||||
body,
|
||||
total,
|
||||
)
|
||||
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)):
|
||||
op_multi = len(op_keys) > 1
|
||||
for key in op_keys:
|
||||
try:
|
||||
src_rel = key_paths[key]
|
||||
src_abs = _resolve_from_relpath(src_rel, request=request)
|
||||
|
||||
if op_multi:
|
||||
if not dst_is_dir:
|
||||
raise BadRequest(
|
||||
"Destination must be an existing directory for multiple keys"
|
||||
)
|
||||
dst_item_rel = (
|
||||
dst_rel / src_rel.name
|
||||
if dst_rel.parts
|
||||
else PurePosixPath(src_rel.name)
|
||||
)
|
||||
elif dst_is_dir:
|
||||
dst_item_rel = (
|
||||
dst_rel / src_rel.name
|
||||
if dst_rel.parts
|
||||
else PurePosixPath(src_rel.name)
|
||||
)
|
||||
else:
|
||||
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 src_abs.is_dir() and dst_exists and dst_abs.is_file():
|
||||
raise BadRequest(
|
||||
"Cannot move/copy a directory to an existing file"
|
||||
)
|
||||
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=""):
|
||||
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,182 @@
|
||||
"""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 json
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import subprocess
|
||||
import threading
|
||||
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 jwt
|
||||
from sanic.log import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_onlyoffice_url() -> str:
|
||||
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
|
||||
|
||||
|
||||
def _get_jwt_secret() -> str | None:
|
||||
return os.environ.get("ONLYOFFICE_JWT_SECRET") or None
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||
url = _get_onlyoffice_url()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
|
||||
def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
||||
"""Convert *file_path* to PNG using OnlyOffice Document Server.
|
||||
|
||||
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||
"""
|
||||
oo_url = _get_onlyoffice_url().rstrip("/")
|
||||
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||
|
||||
# Start temporary HTTP server so OnlyOffice can fetch the file
|
||||
doc_url, httpd = _serve_file_temporarily(file_path)
|
||||
try:
|
||||
suffix = file_path.suffix.lstrip(".").lower()
|
||||
payload = {
|
||||
"async": False,
|
||||
"filetype": suffix,
|
||||
"key": f"cista_{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:
|
||||
headers["Authorization"] = token
|
||||
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
convert_url,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
t_start = perf_counter()
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||
body = resp.read()
|
||||
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
|
||||
with urllib.request.urlopen(file_url, timeout=timeout) as png_resp: # noqa: S310
|
||||
return png_resp.read()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
+518
-79
@@ -1,8 +1,15 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import gc
|
||||
import io
|
||||
import mimetypes
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import PurePosixPath
|
||||
from time import perf_counter
|
||||
from urllib.parse import unquote
|
||||
@@ -10,123 +17,509 @@ from wsgiref.handlers import format_date_time
|
||||
|
||||
import av
|
||||
import fitz # PyMuPDF
|
||||
import msgspec
|
||||
import numpy as np
|
||||
import pillow_heif
|
||||
from PIL import Image
|
||||
import pyvips
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import auth, config
|
||||
from cista import auth, config, sharefs
|
||||
from cista.preview_worker import PreviewRequest, PreviewResponse
|
||||
from cista.util.filename import sanitize
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
# OnlyOffice integration is loaded lazily; availability is checked at runtime.
|
||||
_onlyoffice = None
|
||||
|
||||
|
||||
def _get_onlyoffice():
|
||||
global _onlyoffice
|
||||
if _onlyoffice is None:
|
||||
try:
|
||||
from cista import onlyoffice as oo
|
||||
|
||||
_onlyoffice = oo
|
||||
except Exception:
|
||||
_onlyoffice = False
|
||||
return _onlyoffice
|
||||
|
||||
|
||||
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 = 3.0 # seconds until preview subprocess is killed
|
||||
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||
_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):
|
||||
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")
|
||||
|
||||
line = (
|
||||
msgspec.json.encode(
|
||||
PreviewRequest(
|
||||
path=str(filepath),
|
||||
quality=quality,
|
||||
maxsize=maxsize,
|
||||
maxzoom=maxzoom,
|
||||
)
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
self.proc.stdin.write(line)
|
||||
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:
|
||||
if self.proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
self.proc.kill()
|
||||
await self.proc.wait()
|
||||
_active_procs.discard(self.proc)
|
||||
|
||||
|
||||
class _PreviewWorkerPool:
|
||||
def __init__(self, size: int):
|
||||
self.size = size
|
||||
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
|
||||
self._workers: set[_PreviewWorker] = set()
|
||||
self._closed = False
|
||||
|
||||
async def _spawn_worker(self) -> _PreviewWorker:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"cista.preview_worker",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
_active_procs.add(proc)
|
||||
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)
|
||||
await worker.kill()
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
await self._add_worker()
|
||||
except Exception:
|
||||
logger.exception("Failed to replace preview worker")
|
||||
|
||||
async def start(self) -> None:
|
||||
for _ in range(self.size):
|
||||
await self._add_worker()
|
||||
|
||||
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
||||
if self._closed:
|
||||
raise PreviewError("preview worker pool closed")
|
||||
worker = await self._idle.get()
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(filepath, quality, maxsize, maxzoom),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
return out, resp
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||
)
|
||||
raise PreviewTimeoutError(filepath.name) from None
|
||||
except WorkerChecksumError as e:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
raise PreviewError(f"worker checksum mismatch for {filepath.name}") from e
|
||||
except PreviewError:
|
||||
raise
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.json.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||
)
|
||||
raise PreviewError(
|
||||
f"worker protocol failure for {filepath.name}: {e}"
|
||||
) from e
|
||||
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 close(self) -> None:
|
||||
self._closed = True
|
||||
workers = list(self._workers)
|
||||
self._workers.clear()
|
||||
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)
|
||||
|
||||
|
||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||
EXIF_ORI = {
|
||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||
3: Image.Transpose.ROTATE_180,
|
||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
||||
5: Image.Transpose.TRANSPOSE,
|
||||
6: Image.Transpose.ROTATE_270,
|
||||
7: Image.Transpose.TRANSVERSE,
|
||||
8: Image.Transpose.ROTATE_90,
|
||||
class PreviewTimeoutError(Exception):
|
||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _run_preview_process(
|
||||
filepath, quality: int, maxsize: int, maxzoom: float
|
||||
) -> 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)
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
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>")
|
||||
async def preview(req, path):
|
||||
"""Preview a file"""
|
||||
maxsize = int(req.args.get("px", 1024))
|
||||
maxzoom = float(req.args.get("zoom", 2.0))
|
||||
quality = int(req.args.get("q", 60))
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
filepath = config.config.path / rel
|
||||
stat = filepath.lstat()
|
||||
share_token = auth.request_share_token(req)
|
||||
if share_token is not None:
|
||||
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(
|
||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||
).hex()
|
||||
|
||||
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:
|
||||
img, preview_resp = await _run_preview_process(
|
||||
filepath, quality, maxsize, maxzoom
|
||||
)
|
||||
except PreviewTimeoutError:
|
||||
return empty(504)
|
||||
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)
|
||||
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)
|
||||
|
||||
# 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 = {
|
||||
"etag": etag,
|
||||
"last-modified": format_date_time(stat.st_mtime),
|
||||
"cache-control": "max-age=604800, immutable"
|
||||
+ ("" if config.config.public else ", private"),
|
||||
"content-type": "image/avif",
|
||||
"content-type": preview_mime,
|
||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||
}
|
||||
if req.headers.if_none_match == etag:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||
|
||||
if not filepath.is_file():
|
||||
raise NotFound("File not found")
|
||||
|
||||
img = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||
)
|
||||
if not img:
|
||||
# Preview generation failed, redirect to the file itself
|
||||
return redirect(f"/files/{path}", status=303)
|
||||
return raw(img, headers=headers)
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom):
|
||||
backend = "unknown"
|
||||
try:
|
||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||
backend = "pdf"
|
||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||
type, _ = mimetypes.guess_type(path.name)
|
||||
if type and type.startswith("video/"):
|
||||
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||
backend = "onlyoffice"
|
||||
return process_office(
|
||||
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)
|
||||
return process_image(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:
|
||||
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating preview for {path}: {e}")
|
||||
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):
|
||||
t_load = perf_counter()
|
||||
with Image.open(path) as img:
|
||||
# Force decode to include I/O in load timing
|
||||
img.load()
|
||||
t_proc = perf_counter()
|
||||
# Resize
|
||||
w, h = img.size
|
||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||
# Transpose pixels according to EXIF Orientation
|
||||
orientation = img.getexif().get(274, 1)
|
||||
if orientation in EXIF_ORI:
|
||||
img = img.transpose(EXIF_ORI[orientation])
|
||||
# Save as AVIF
|
||||
imgdata = io.BytesIO()
|
||||
t_save = perf_counter()
|
||||
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
|
||||
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||
|
||||
t_end = perf_counter()
|
||||
ret = imgdata.getvalue()
|
||||
|
||||
load_ms = (t_proc - t_load) * 1000
|
||||
proc_ms = (t_save - t_proc) * 1000
|
||||
save_ms = (t_end - t_save) * 1000
|
||||
logger.debug(
|
||||
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
|
||||
path.name,
|
||||
load_ms,
|
||||
proc_ms,
|
||||
save_ms,
|
||||
def process_image_pyvips(path, *, maxsize, quality):
|
||||
t_start = perf_counter()
|
||||
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
||||
img = img.autorot()
|
||||
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,
|
||||
strip=True,
|
||||
)
|
||||
t_end = perf_counter()
|
||||
|
||||
return ret
|
||||
return ret, PreviewResponse(
|
||||
ok=True,
|
||||
mime="image/avif",
|
||||
backend="pyvips",
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
)
|
||||
|
||||
|
||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
@@ -140,16 +533,52 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
|
||||
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, strip=True)
|
||||
backend = "pdf+pyvips"
|
||||
t_save_end = perf_counter()
|
||||
|
||||
logger.debug(
|
||||
"Preview pdf %s: load+render=%.1fms save=%.1fms",
|
||||
path.name,
|
||||
(t_load_end - t_load_start) * 1000,
|
||||
(t_save_end - t_save_start) * 1000,
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def process_office(path, *, quality, maxsize, maxzoom):
|
||||
t_load_start = perf_counter()
|
||||
oo = _get_onlyoffice()
|
||||
if oo is False:
|
||||
raise RuntimeError("OnlyOffice is not installed")
|
||||
if not oo.is_available():
|
||||
raise RuntimeError("OnlyOffice Document Server is not reachable")
|
||||
png_bytes = oo.convert_to_png(path)
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
img = pyvips.Image.new_from_buffer(png_bytes, "")
|
||||
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, strip=True)
|
||||
backend = "onlyoffice+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),
|
||||
],
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
def process_video(path, *, maxsize, quality):
|
||||
@@ -162,7 +591,13 @@ def process_video(path, *, maxsize, quality):
|
||||
t_save_start = t_load_start
|
||||
t_save_end = t_load_start
|
||||
with (
|
||||
av.open(str(path)) as icontainer,
|
||||
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]
|
||||
@@ -237,7 +672,8 @@ def process_video(path, *, maxsize, quality):
|
||||
"threads": "1",
|
||||
},
|
||||
)
|
||||
assert isinstance(ostream, av.VideoStream)
|
||||
if not isinstance(ostream, av.VideoStream):
|
||||
raise PreviewError("failed to initialize AV1 video stream")
|
||||
ostream.width = frame.width
|
||||
ostream.height = frame.height
|
||||
ostream.pix_fmt = frame.format.name
|
||||
@@ -254,14 +690,17 @@ def process_video(path, *, maxsize, quality):
|
||||
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||
t_save_end = perf_counter()
|
||||
|
||||
# Capture frame dimensions before cleanup
|
||||
# Capture result before cleanup
|
||||
ret = imgdata.getvalue()
|
||||
logger.debug(
|
||||
"Preview video %s: load+decode=%.1fms save=%.1fms",
|
||||
path.name,
|
||||
(t_load_end - t_load_start) * 1000,
|
||||
(t_save_end - t_save_start) * 1000,
|
||||
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),
|
||||
],
|
||||
)
|
||||
del imgdata, istream, ostream, icc, occ, frame
|
||||
gc.collect()
|
||||
return ret
|
||||
return ret, resp
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Preview generation worker subprocess.
|
||||
|
||||
Two modes are supported:
|
||||
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
|
||||
|
||||
Framed response format:
|
||||
(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 io
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from blake3 import blake3
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_enc = msgspec.json.Encoder()
|
||||
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||
|
||||
|
||||
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||
meta_bytes = _enc.encode(resp)
|
||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||
checksum = blake3(packet).digest()
|
||||
sys.stdout.buffer.write(checksum)
|
||||
sys.stdout.buffer.write(packet)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
from cista.preview import dispatch
|
||||
|
||||
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:
|
||||
from cista.preview import dispatch
|
||||
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return
|
||||
stderr_capture = io.StringIO()
|
||||
handler = logging.StreamHandler(stderr_capture)
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(handler)
|
||||
try:
|
||||
with contextlib.redirect_stderr(stderr_capture):
|
||||
req = _dec_req.decode(line)
|
||||
result, resp = dispatch(
|
||||
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
||||
)
|
||||
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:
|
||||
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.
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
if len(sys.argv) > 1:
|
||||
_run_once()
|
||||
return
|
||||
_run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
-129
@@ -1,136 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
"""Return list of paths affected by this operation for change notification."""
|
||||
return []
|
||||
|
||||
|
||||
class MkDir(ControlBase):
|
||||
path: str
|
||||
|
||||
def __call__(self):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(self.path)]
|
||||
|
||||
|
||||
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))
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
sanitized = filename.sanitize(self.path)
|
||||
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
|
||||
return [sanitized, new_path]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(p) for p in self.sel]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
paths = [filename.sanitize(p) for p in self.sel]
|
||||
# Include new locations in dst
|
||||
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
|
||||
return paths
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
# Only destinations are new (sources unchanged)
|
||||
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
|
||||
|
||||
|
||||
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):
|
||||
@@ -146,6 +18,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
key: str
|
||||
mtime: int
|
||||
size: int
|
||||
allocated: int
|
||||
isfile: int
|
||||
|
||||
def __str__(self):
|
||||
@@ -177,5 +50,6 @@ class UpdateMessage(msgspec.Struct):
|
||||
class Space(msgspec.Struct):
|
||||
disk: int
|
||||
free: int
|
||||
usage: int
|
||||
used: int
|
||||
storage: int
|
||||
allocated: int
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Custom access logging middleware for Sanic."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import unicodedata
|
||||
from ipaddress import IPv6Address
|
||||
|
||||
logger = logging.getLogger("cista.access")
|
||||
|
||||
_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 _display_width(text: str) -> int:
|
||||
width = 0
|
||||
for char in text:
|
||||
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
|
||||
return width
|
||||
|
||||
|
||||
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 = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
_LEVEL_EMOJI = {
|
||||
logging.DEBUG: "🔍",
|
||||
logging.INFO: "i",
|
||||
logging.WARNING: "⚠️",
|
||||
logging.ERROR: "🛑",
|
||||
logging.CRITICAL: "🛑",
|
||||
}
|
||||
|
||||
|
||||
class _EmojiFormatter(logging.Formatter):
|
||||
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
|
||||
sep = " " if record.levelno in (logging.INFO, logging.WARNING) else " "
|
||||
return f"{emoji}{sep}{record.getMessage()}"
|
||||
|
||||
|
||||
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().
|
||||
"""
|
||||
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||
|
||||
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())
|
||||
+6
-11
@@ -12,7 +12,7 @@ def run(*, dev=False):
|
||||
"""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
|
||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||
confdir = config.conffile.parent
|
||||
@@ -21,14 +21,14 @@ def run(*, dev=False):
|
||||
server80.app.prepare(port=80, motd=False)
|
||||
domain = opts["host"]
|
||||
check_cert(confdir / domain, domain)
|
||||
opts["ssl"] = str(confdir / domain) # type: ignore
|
||||
opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
|
||||
app.prepare(
|
||||
**opts,
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
access_log=True,
|
||||
) # type: ignore
|
||||
access_log=False,
|
||||
) # type: ignore[call-arg]
|
||||
if dev:
|
||||
Sanic.serve()
|
||||
else:
|
||||
@@ -38,7 +38,7 @@ def run(*, dev=False):
|
||||
def check_cert(certdir, domain):
|
||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||
return
|
||||
# TODO: Use certbot to fetch a cert
|
||||
# Certificate provisioning is external; files must exist before startup.
|
||||
raise ValueError(
|
||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
||||
)
|
||||
@@ -62,11 +62,6 @@ def parse_listen(listen):
|
||||
return "http://localhost", {"unix": unix.as_posix()}
|
||||
|
||||
host, port = ep["host"], ep["port"]
|
||||
# When binding all interfaces, use single_listener=False for Sanic
|
||||
if len(endpoints) > 1:
|
||||
return f"http://localhost:{port}", {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"single_listener": False,
|
||||
}
|
||||
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||
|
||||
+41
-28
@@ -1,43 +1,56 @@
|
||||
import secrets
|
||||
from time import time
|
||||
|
||||
import jwt
|
||||
|
||||
from cista.config import derived_secret
|
||||
|
||||
|
||||
def session_secret():
|
||||
return derived_secret("session")
|
||||
# In-memory session store: token -> {"username": str, "exp": int}
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
SESSION_COOKIE_NAME = "cista"
|
||||
|
||||
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):
|
||||
try:
|
||||
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
||||
except Exception:
|
||||
return False if "s" in request.cookies else None
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token is None:
|
||||
return 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):
|
||||
data = {
|
||||
"exp": int(time()) + max_age,
|
||||
"username": username,
|
||||
**kwargs,
|
||||
}
|
||||
s = jwt.encode(data, session_secret())
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
def create(request, res, username, **kwargs):
|
||||
_purge_expired()
|
||||
token = _token()
|
||||
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
|
||||
secure = request.scheme == "https"
|
||||
res.cookies.add_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
token,
|
||||
httponly=True,
|
||||
max_age=max_age,
|
||||
secure=secure,
|
||||
host_prefix=secure,
|
||||
)
|
||||
|
||||
|
||||
def update(res, s, **kwargs):
|
||||
s.update(kwargs)
|
||||
s = jwt.encode(s, session_secret())
|
||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
|
||||
|
||||
def delete(res):
|
||||
res.cookies.delete_cookie("s")
|
||||
def delete(request, res):
|
||||
secure = request.scheme == "https"
|
||||
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
|
||||
|
||||
|
||||
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
|
||||
+100
-53
@@ -15,7 +15,8 @@ import re
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from sanic import Blueprint
|
||||
from sanic import Blueprint, json
|
||||
from sanic import raw as raw_response
|
||||
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||
from sanic.log import logger
|
||||
|
||||
@@ -48,6 +49,8 @@ async def get_client() -> httpx.AsyncClient:
|
||||
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
|
||||
|
||||
|
||||
@@ -123,22 +126,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
||||
context=error_data,
|
||||
quiet=True,
|
||||
)
|
||||
elif response.status_code == 403:
|
||||
if response.status_code == 403:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Access denied"),
|
||||
context=error_data,
|
||||
quiet=True,
|
||||
)
|
||||
else:
|
||||
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,
|
||||
)
|
||||
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}")
|
||||
@@ -146,8 +148,64 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
||||
"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.
|
||||
@@ -171,10 +229,10 @@ async def proxy_auth_request(request):
|
||||
"upgrade",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
}
|
||||
|
||||
headers = [
|
||||
@@ -182,9 +240,17 @@ async def proxy_auth_request(request):
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in skip_headers
|
||||
]
|
||||
headers.append(("x-forwarded-for", request.client_ip))
|
||||
|
||||
# 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(
|
||||
@@ -210,8 +276,6 @@ async def proxy_auth_request(request):
|
||||
if key.lower() not in resp_hop_by_hop
|
||||
]
|
||||
|
||||
from sanic import raw as raw_response
|
||||
|
||||
return raw_response(
|
||||
raw_content,
|
||||
status=response.status_code,
|
||||
@@ -221,35 +285,31 @@ async def proxy_auth_request(request):
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Auth proxy request failed: {e}")
|
||||
from sanic import json
|
||||
|
||||
return json(
|
||||
{"detail": "Authentication service unavailable", "error": str(e)},
|
||||
{"detail": "Authentication service unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_websocket(request, ws):
|
||||
"""Proxy a WebSocket connection to the auth backend."""
|
||||
path = request.path
|
||||
query_string = request.query_string
|
||||
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
||||
"https://", "wss://"
|
||||
)
|
||||
url = f"{ws_backend}{path}"
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
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.ip
|
||||
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||
additional_headers["x-forwarded-host"] = request.host
|
||||
additional_headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
@@ -262,15 +322,15 @@ async def proxy_auth_websocket(request, ws):
|
||||
try:
|
||||
async for message in ws:
|
||||
await backend_ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("WebSocket forward_to_client ended: %s", e)
|
||||
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
@@ -281,23 +341,20 @@ async def proxy_auth_websocket(request, ws):
|
||||
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||
|
||||
|
||||
def _is_websocket_request(request) -> bool:
|
||||
"""Check if the request is a WebSocket upgrade request."""
|
||||
connection = request.headers.get("connection", "").lower()
|
||||
upgrade = request.headers.get("upgrade", "").lower()
|
||||
connection_tokens = [t.strip() for t in connection.split(",")]
|
||||
return "upgrade" in connection_tokens and upgrade == "websocket"
|
||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||
bp = Blueprint("sso", url_prefix="/auth")
|
||||
|
||||
|
||||
async def _handle_websocket_upgrade(request):
|
||||
"""Handle WebSocket upgrade and proxy the connection."""
|
||||
protocol = request.transport.get_protocol()
|
||||
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
||||
@bp.websocket("/ws/<path:path>")
|
||||
async def auth_websocket_proxy(request, ws, path=""):
|
||||
"""Proxy WebSocket connections to the auth backend."""
|
||||
await proxy_auth_websocket(request, ws)
|
||||
|
||||
|
||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||
bp = Blueprint("sso", url_prefix="/auth")
|
||||
@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(
|
||||
@@ -305,20 +362,10 @@ bp = Blueprint("sso", url_prefix="/auth")
|
||||
)
|
||||
async def auth_proxy(request, path=""):
|
||||
"""Proxy all auth requests to the auth backend."""
|
||||
if _is_websocket_request(request):
|
||||
await _handle_websocket_upgrade(request)
|
||||
from sanic import empty
|
||||
|
||||
return empty()
|
||||
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."""
|
||||
if _is_websocket_request(request):
|
||||
await _handle_websocket_upgrade(request)
|
||||
from sanic import empty
|
||||
|
||||
return empty()
|
||||
return await proxy_auth_request(request)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import msgspec
|
||||
@@ -8,6 +9,7 @@ from sanic.response import raw, redirect
|
||||
|
||||
from cista import auth
|
||||
from cista.protocol import ErrorMsg
|
||||
from cista.sanic_logging import log_ws_close, log_ws_open
|
||||
|
||||
|
||||
def asend(ws, msg):
|
||||
@@ -22,11 +24,13 @@ def jres(data, **kwargs):
|
||||
|
||||
async def handle_sanic_exception(request, e):
|
||||
context, code = {}, 500
|
||||
headers = None
|
||||
message = str(e)
|
||||
if isinstance(e, SanicException):
|
||||
context = e.context or {}
|
||||
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 = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
if code == 500:
|
||||
@@ -39,6 +43,7 @@ async def handle_sanic_exception(request, e):
|
||||
return jres(
|
||||
response_data,
|
||||
status=code,
|
||||
headers=headers,
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
if "redirect" in context:
|
||||
@@ -54,6 +59,11 @@ def websocket_wrapper(handler):
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapper(request, ws, *args, **kwargs):
|
||||
username = getattr(request.ctx, "username", None)
|
||||
extra = username if username else None
|
||||
start = time.perf_counter()
|
||||
ws_id = log_ws_open(request, extra=extra)
|
||||
close_extra = None
|
||||
try:
|
||||
await auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
@@ -66,6 +76,21 @@ def websocket_wrapper(handler):
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
if not getattr(e, "quiet", False) or code == 500:
|
||||
logger.exception(f"{code} {e!r}")
|
||||
close_extra = f"{code} {message}"
|
||||
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
|
||||
|
||||
@@ -40,7 +40,7 @@ class AsyncLink:
|
||||
async def stop(self):
|
||||
"""Stop worker and clean up."""
|
||||
while not self.queue.empty():
|
||||
command, future = self.queue.get_nowait()
|
||||
_command, future = self.queue.get_nowait()
|
||||
if not future.done():
|
||||
future.set_exception(Exception("AsyncLink stopped"))
|
||||
self.queue.task_done()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from time import monotonic
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LRUCache:
|
||||
@@ -7,22 +7,22 @@ class LRUCache:
|
||||
LRUCache is a least-recently-used (LRU) cache with expiry time.
|
||||
|
||||
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.
|
||||
maxage (float): Max age for items in cache in seconds.
|
||||
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.
|
||||
|
||||
Args:
|
||||
open (callable): Function to open a new handle.
|
||||
opener (callable): Function to open a new handle.
|
||||
capacity (int): Maximum capacity of the cache.
|
||||
maxage (float): Max age for items in cache in seconds.
|
||||
"""
|
||||
self.open = open
|
||||
self.opener = opener
|
||||
self.capacity = capacity
|
||||
self.maxage = maxage
|
||||
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
|
||||
@@ -47,7 +47,7 @@ class LRUCache:
|
||||
self.cache.pop(i)
|
||||
break
|
||||
else:
|
||||
f = self.open(key)
|
||||
f = self.opener(key)
|
||||
# Add/restore to end of cache
|
||||
self.cache.insert(0, (key, f, monotonic()))
|
||||
self.expire_items()
|
||||
@@ -58,7 +58,9 @@ class LRUCache:
|
||||
Expire items that are either too old or exceed cache capacity.
|
||||
"""
|
||||
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()
|
||||
|
||||
def close(self):
|
||||
|
||||
+1028
-53
File diff suppressed because it is too large
Load Diff
+118
-16
@@ -17,6 +17,33 @@ from cista import config
|
||||
from cista.fileio import fuid
|
||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||
|
||||
# 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."""
|
||||
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."""
|
||||
# st_blocks is in 512-byte units
|
||||
return st.st_blocks * 512
|
||||
|
||||
|
||||
pubsub = {}
|
||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||
|
||||
@@ -24,7 +51,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self._space = Space(0, 0, 0, 0)
|
||||
self._space = Space(0, 0, 0, 0, 0)
|
||||
self.root: list[FileEntry] = []
|
||||
|
||||
@property
|
||||
@@ -114,8 +141,8 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
|
||||
|
||||
|
||||
state = State()
|
||||
rootpath: Path = None # type: ignore
|
||||
quit = threading.Event()
|
||||
rootpath: Path | None = None
|
||||
stop_event = threading.Event()
|
||||
|
||||
# Thread-safe queue for signaling path updates from websockets
|
||||
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||
@@ -123,9 +150,8 @@ _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||
|
||||
def notify_change(*paths: PurePosixPath | str):
|
||||
"""Signal that paths have changed. Called from control/upload websockets."""
|
||||
for path in paths:
|
||||
if isinstance(path, str):
|
||||
path = PurePosixPath(path)
|
||||
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 = (
|
||||
@@ -148,21 +174,27 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
try:
|
||||
st = stat or path.stat()
|
||||
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
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
key=fuid(st),
|
||||
mtime=int(st.st_mtime),
|
||||
size=st.st_size if isfile else 0,
|
||||
allocated=allocated,
|
||||
isfile=isfile,
|
||||
)
|
||||
if isfile:
|
||||
return [entry]
|
||||
# Walk all entries of the directory
|
||||
ret: list[FileEntry] = [...] # type: ignore
|
||||
ret: list[FileEntry] = [...] # type: ignore[assignment]
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if quit.is_set():
|
||||
if stop_event.is_set():
|
||||
raise SystemExit("quit")
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
@@ -181,8 +213,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
level=entry.level,
|
||||
name=entry.name,
|
||||
key=entry.key,
|
||||
size=entry.size + child.size,
|
||||
mtime=max(entry.mtime, child.mtime),
|
||||
size=entry.size + child.size,
|
||||
allocated=entry.allocated + child.allocated,
|
||||
isfile=entry.isfile,
|
||||
)
|
||||
ret.extend(sub)
|
||||
@@ -227,7 +260,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||
def update_space(loop):
|
||||
"""Called periodically to update the disk usage."""
|
||||
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
|
||||
tol = 10**6
|
||||
old = msgspec.structs.astuple(state.space)
|
||||
@@ -467,7 +507,7 @@ class PathIndex:
|
||||
|
||||
if lo < len(children):
|
||||
return children[lo]
|
||||
elif children:
|
||||
if children:
|
||||
# Insert after last child's subtree
|
||||
last_idx = children[-1]
|
||||
last_entry = self.root[last_idx]
|
||||
@@ -504,8 +544,66 @@ class PathIndex:
|
||||
|
||||
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
|
||||
current = current.parent
|
||||
|
||||
# Process from deepest ancestor to root
|
||||
for ancestor_path in ancestors:
|
||||
if ancestor_path not in self._index:
|
||||
continue
|
||||
start, count = self._index[ancestor_path]
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
ancestor = self.root[start]
|
||||
if ancestor.isfile:
|
||||
continue # Files don't aggregate
|
||||
|
||||
# Sum size/allocated of direct children
|
||||
total_size = 0
|
||||
total_allocated = 0
|
||||
i = start + 1
|
||||
while i < start + count:
|
||||
child = self.root[i]
|
||||
if child.level == ancestor.level + 1:
|
||||
total_size += child.size
|
||||
total_allocated += child.allocated
|
||||
# Skip child's subtree
|
||||
child_path = ancestor_path / child.name
|
||||
if child_path in self._index:
|
||||
_, child_count = self._index[child_path]
|
||||
i += child_count
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Update ancestor entry if changed
|
||||
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||
self.root[start] = FileEntry(
|
||||
level=ancestor.level,
|
||||
name=ancestor.name,
|
||||
key=ancestor.key,
|
||||
mtime=ancestor.mtime,
|
||||
size=total_size,
|
||||
allocated=total_allocated,
|
||||
isfile=ancestor.isfile,
|
||||
)
|
||||
|
||||
|
||||
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||
"""Remove child paths if parent is in set."""
|
||||
@@ -557,7 +655,7 @@ def watcher(loop):
|
||||
)
|
||||
)
|
||||
|
||||
while not quit.is_set():
|
||||
while not stop_event.is_set():
|
||||
if use_inotify:
|
||||
import inotify.adapters
|
||||
|
||||
@@ -575,7 +673,11 @@ def watcher(loop):
|
||||
first_event_time: float | None = None
|
||||
last_event_time: float | None = None
|
||||
|
||||
def add_dirty(path: PurePosixPath, source: str) -> bool:
|
||||
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
|
||||
@@ -609,7 +711,7 @@ def watcher(loop):
|
||||
last_event_time = now
|
||||
return True
|
||||
|
||||
while not quit.is_set():
|
||||
while not stop_event.is_set():
|
||||
now = time.monotonic()
|
||||
|
||||
# Full refresh every 300s
|
||||
@@ -680,7 +782,7 @@ def watcher(loop):
|
||||
# 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 quit.is_set():
|
||||
if stop_event.is_set():
|
||||
return
|
||||
if not (modified_flags & set(event[1])):
|
||||
continue
|
||||
@@ -724,5 +826,5 @@ def start(app):
|
||||
|
||||
|
||||
def stop(app):
|
||||
quit.set()
|
||||
stop_event.set()
|
||||
app.ctx.watcher.join()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-17
@@ -9,8 +9,10 @@
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format --check .",
|
||||
"check": "biome check ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -30,33 +32,19 @@
|
||||
"vue-router": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.15.0",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tsconfig/node18": "^18.2.6",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 88
|
||||
}
|
||||
}
|
||||
|
||||
+123
-31
@@ -7,6 +7,7 @@
|
||||
</div>
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<UserTokensModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
@@ -16,26 +17,28 @@
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</main>
|
||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
<SelectionToolbar :path="path.pathList" />
|
||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import { computed } from 'vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import UserTokensModal from './components/UserTokensModal.vue'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
@@ -54,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||
document.title =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
})
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
@@ -62,6 +68,7 @@ onUnmounted(watchDisconnect)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (store.dialog) {
|
||||
if (timer) {
|
||||
@@ -75,6 +82,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
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.key === 'ArrowUp' ||
|
||||
@@ -90,13 +104,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
||||
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
|
||||
}
|
||||
arrow = dir
|
||||
}
|
||||
if (arrow) {
|
||||
// Arrow key handling - fall through to bottom
|
||||
}
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (!input && keyup && event.key === '/') {
|
||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||
else if (!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()
|
||||
}
|
||||
// Globally close search, clear errors on Escape
|
||||
@@ -105,8 +138,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
store.clearToast()
|
||||
headerMain.value!.clearSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
} else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
}
|
||||
// Select all (toggle); keydown to precede and prevent builtin
|
||||
@@ -121,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (
|
||||
!input &&
|
||||
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)
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (!input && c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
}
|
||||
else return
|
||||
if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
|
||||
} else return
|
||||
/// We are handling this!
|
||||
event.preventDefault()
|
||||
if (timer) {
|
||||
@@ -142,26 +181,79 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
timer = null
|
||||
}
|
||||
let f: any
|
||||
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
|
||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||
if (arrow && !keyup) {
|
||||
const focusSearch = () =>
|
||||
(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f && !keyup) {
|
||||
if (f) {
|
||||
// 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()
|
||||
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
|
||||
timer = setTimeout(() => {
|
||||
timer = setInterval(f, tr)
|
||||
}, t0 - tr)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
// Use capture phase to handle events before they reach target elements
|
||||
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
--header-color: #ccc;
|
||||
--input-background: var(--soft-color);
|
||||
--input-color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.size,
|
||||
@@ -50,8 +50,12 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
header .headermain { order: 1; }
|
||||
header .breadcrumb { align-self: stretch; }
|
||||
header .headermain {
|
||||
order: 1;
|
||||
}
|
||||
header .breadcrumb {
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
@@ -74,7 +78,7 @@
|
||||
max-width: none !important;
|
||||
}
|
||||
.breadcrumb > a::after {
|
||||
content: '/';
|
||||
content: "/";
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
@@ -101,7 +105,8 @@
|
||||
video::-webkit-media-controls {
|
||||
display: none;
|
||||
}
|
||||
tr, figure {
|
||||
tr,
|
||||
figure {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.selection {
|
||||
@@ -134,7 +139,7 @@ main {
|
||||
body {
|
||||
background-color: var(--primary-background);
|
||||
font-size: 1rem;
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
/* Prevent any scrolling on body */
|
||||
@@ -145,7 +150,7 @@ body {
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
font-family: "Roboto Mono";
|
||||
}
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
@@ -209,9 +214,13 @@ header nav.headermain {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
.spacer { flex-grow: 1 }
|
||||
.smallgap { flex-shrink: 1; width: 2em }
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.smallgap {
|
||||
flex-shrink: 1;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: .5em;
|
||||
|
||||
@@ -60,78 +60,126 @@ 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
|
||||
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,
|
||||
arrow: Arrow,
|
||||
'arrows-h': ArrowsH,
|
||||
'arrows-v': ArrowsV,
|
||||
'check': Check,
|
||||
'code': Code,
|
||||
'cog': Cog,
|
||||
'copy': Copy,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
window: Window,
|
||||
wordwrap: Wordwrap,
|
||||
zoomin: Zoomin,
|
||||
zoomout: Zoomout
|
||||
} as const
|
||||
|
||||
export type IconName = keyof typeof icons
|
||||
|
||||
@@ -37,17 +37,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Home } from '@/assets/svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const home = Home
|
||||
const router = useRouter()
|
||||
|
||||
const links = [] as Array<HTMLElement>
|
||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
||||
const setLinkRef = (index: number, el: any) => {
|
||||
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())
|
||||
@@ -63,7 +67,8 @@ const props = defineProps<{
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
|
||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
||||
const isCurrent = (index: number) =>
|
||||
index == props.path.length ? 'location' : undefined
|
||||
|
||||
const focusCurrent = () => {
|
||||
nextTick(() => {
|
||||
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||
if (isCurrent(index)) {
|
||||
longest.value.splice(index)
|
||||
router.push(u)
|
||||
}
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
else if (long.startsWith(browser)) router.replace(u)
|
||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||
@@ -100,8 +108,7 @@ watchEffect(() => {
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
@@ -111,10 +118,11 @@ watchEffect(() => {
|
||||
}
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
if (props.primary)
|
||||
nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="disk-space-container" ref="containerRef">
|
||||
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||
<div
|
||||
ref="widgetRef"
|
||||
class="disk-space-widget"
|
||||
@@ -36,9 +36,9 @@
|
||||
</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.storage, sectorInfo.storage.angle) }}</text>
|
||||
<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 :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.usage - store.space.storage, sectorInfo.other.angle) }}</text>
|
||||
<text :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" />
|
||||
@@ -62,8 +62,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
|
||||
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
|
||||
return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
|
||||
}
|
||||
|
||||
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
@@ -98,7 +98,31 @@ const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
return name.slice(0, maxLen - 1) + '…'
|
||||
}
|
||||
|
||||
const storageName = computed(() => truncateLabel(store.server.name || 'stored'))
|
||||
// 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 = (s.free / s.disk) * 360
|
||||
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
||||
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||
const gapToFree = (storageSpan + freeSpan) / 2
|
||||
const gapToOther = (storageSpan + otherSpan) / 2
|
||||
const minGap = Math.min(gapToFree, gapToOther)
|
||||
// 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
|
||||
|
||||
@@ -113,7 +137,7 @@ const CIRC = TAU * midRadius
|
||||
const pieStorageDash = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return `0 ${CIRC}`
|
||||
return `${(s.storage / s.disk) * CIRC} ${CIRC}`
|
||||
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||
})
|
||||
|
||||
const pieFreeDash = computed(() => {
|
||||
@@ -125,7 +149,7 @@ const pieFreeDash = computed(() => {
|
||||
const pieFreeOffsetVal = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return 0
|
||||
return -(s.storage / s.disk) * CIRC
|
||||
return -(s.allocated / s.disk) * CIRC
|
||||
})
|
||||
|
||||
const freeColor = computed(() => {
|
||||
@@ -133,31 +157,32 @@ const freeColor = computed(() => {
|
||||
if (!s.disk) return '#6c6'
|
||||
const freePct = s.free / s.disk
|
||||
if (freePct > 0.25) return '#5b5'
|
||||
if (freePct > 0.10) return '#db3'
|
||||
return '#d44'
|
||||
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
|
||||
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 }
|
||||
}
|
||||
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.storage / s.disk
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.usage - s.storage) / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
const freeAngle = freeStart + freePct * 180
|
||||
const otherStart = (storagePct + freePct) * 360
|
||||
@@ -176,13 +201,19 @@ const rawAngles = computed(() => ({
|
||||
other: sectorInfo.value.other.angle
|
||||
}))
|
||||
|
||||
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
||||
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
||||
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))
|
||||
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(() => ({
|
||||
@@ -229,9 +260,10 @@ const adjustedLabelAngles = computed(() => {
|
||||
})
|
||||
|
||||
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||
const createArcPath = (centerAngle: number, id: string) => {
|
||||
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||
const radius = LABEL_RADIUS
|
||||
const arcSpan = 60
|
||||
// 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
|
||||
@@ -244,11 +276,17 @@ const createArcPath = (centerAngle: number, id: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage'))
|
||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free'))
|
||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other'))
|
||||
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!, 'other', 5)
|
||||
)
|
||||
|
||||
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||
|
||||
const applyAnimState = (t: number, opacity: number) => {
|
||||
const widget = widgetRef.value
|
||||
@@ -271,9 +309,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
|
||||
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 eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const t = expanding ? eased : 1 - eased
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(tick)
|
||||
} else {
|
||||
@@ -327,6 +365,11 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.disk-space-widget {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { zipName } from '@/utils/fileutil'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -24,9 +24,9 @@ const status_init = {
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
status: 'idle'
|
||||
}
|
||||
store.dprogress = {...status_init}
|
||||
store.dprogress = { ...status_init }
|
||||
setInterval(() => {
|
||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||
// Reset
|
||||
@@ -34,8 +34,8 @@ setInterval(() => {
|
||||
store.dprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.dprogress.statbytes *= .9
|
||||
store.dprogress.statdur *= .9
|
||||
store.dprogress.statbytes *= 0.9
|
||||
store.dprogress.statdur *= 0.9
|
||||
}
|
||||
}, 100)
|
||||
const statReset = () => {
|
||||
@@ -44,10 +44,9 @@ const statReset = () => {
|
||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||
}
|
||||
const cancelDownloads = () => {
|
||||
location.reload() // FIXME
|
||||
location.reload() // FIXME
|
||||
}
|
||||
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
|
||||
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
await filesystemdl(sel, handle)
|
||||
store.selected.clear()
|
||||
} catch (e) {
|
||||
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
|
||||
// Default: ZIP download
|
||||
zipdl(sel)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
const props = defineProps<{
|
||||
path: string[],
|
||||
documents: Document[],
|
||||
path: string[]
|
||||
documents: Document[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -72,14 +72,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -87,35 +95,44 @@ const props = defineProps<{
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
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
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
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
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
console.log("New folder")
|
||||
console.log('New folder')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
@@ -124,6 +141,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -134,6 +152,19 @@ defineExpose({
|
||||
isCursor() {
|
||||
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 (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -147,11 +178,22 @@ defineExpose({
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
left(ev: KeyboardEvent) { router.back() },
|
||||
up(ev: KeyboardEvent) {
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
this.cursorMove(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) {
|
||||
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()
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
@@ -165,8 +207,9 @@ defineExpose({
|
||||
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 =
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
||||
const index = store.cursor
|
||||
? docs.findIndex(doc => doc.key === store.cursor)
|
||||
: docs.length
|
||||
const moveto = increment(index, d)
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
@@ -184,14 +227,23 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -209,7 +261,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
@@ -220,39 +272,31 @@ const updateModified = () => {
|
||||
nowkey.value = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
onMounted(() => {
|
||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
||||
updateModified()
|
||||
modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(modifiedTimer)
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
router.push(doc.urlrouter)
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -330,12 +374,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
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'))
|
||||
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 {
|
||||
@@ -348,24 +394,17 @@ const copyImage = async (doc: Doc) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const deleteFile = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(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) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
now: number
|
||||
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
|
||||
props.now // trigger reactivity
|
||||
return formatUnixDate(props.doc.mtime)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const name = ref('')
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
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
|
||||
return +unit ? 'bytes' : unit
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const tooltipText = computed(() => {
|
||||
const { size, allocated } = props.doc
|
||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.before-size {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
.size.empty { color: #555 }
|
||||
.size.bytes { color: #77a }
|
||||
.size.kB { color: #474 }
|
||||
|
||||
@@ -9,13 +9,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -23,38 +31,51 @@ const props = defineProps<{
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
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
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => { editing.value = null }
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const exit = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
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
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
||||
' '
|
||||
).length
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
@@ -67,6 +88,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -81,6 +103,19 @@ defineExpose({
|
||||
isCursor() {
|
||||
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 (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -94,10 +129,18 @@ defineExpose({
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
|
||||
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
up(ev: KeyboardEvent) {
|
||||
this.cursorMove(-columns.value, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
this.cursorMove(columns.value, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
this.cursorMove(1, ev)
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
@@ -109,11 +152,10 @@ defineExpose({
|
||||
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 =
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
// Stop navigation sideways away from the grid (only with up/down)
|
||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
||||
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||
// Calculate new position
|
||||
let moveto
|
||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||
@@ -138,14 +180,23 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -156,12 +207,17 @@ watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus()
|
||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
@@ -182,31 +238,20 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
router.push(doc.urlrouter)
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -274,12 +319,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
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'))
|
||||
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 {
|
||||
@@ -292,24 +339,17 @@ const copyImage = async (doc: Doc) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const deleteFile = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(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) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||
<span>{{ doc.name }}</span>
|
||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
</figcaption>
|
||||
@@ -26,32 +26,40 @@
|
||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||
<div class="tooltip-name">{{ doc.name }}</div>
|
||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||
</CursorTooltip>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
<script setup lang="ts">
|
||||
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 CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void;
|
||||
exit: () => void;
|
||||
rename: (name: string) => void
|
||||
exit: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc,
|
||||
editing?: EditingProp,
|
||||
doc: Doc
|
||||
editing?: EditingProp
|
||||
}>()
|
||||
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)}`
|
||||
})
|
||||
|
||||
const onclick = (ev: Event) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
store.cursor = props.doc.key
|
||||
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
|
||||
.tooltip-details {
|
||||
text-align: center;
|
||||
}
|
||||
.tooltip-sparse {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.after-name {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
figure {
|
||||
max-height: 15em;
|
||||
position: relative;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
@@ -17,7 +17,13 @@
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div v-if="showSortHints" class="sort-hints">
|
||||
<span class="sort-label">Order</span>
|
||||
<span class="keycap">1</span>
|
||||
<span class="keycap">2</span>
|
||||
<span class="keycap">3</span>
|
||||
</div>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
@@ -26,24 +32,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||
import { ref } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { showAuthIframe } from 'paskia'
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import DiskSpace from './DiskSpace.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const ssoStore = useSsoAuthStore()
|
||||
const search = ref<HTMLInputElement | null>()
|
||||
const textInputFocused = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
const isInputElement = (el: Element | null): boolean => {
|
||||
if (!el || !(el instanceof HTMLElement)) return 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) {
|
||||
@@ -78,7 +96,7 @@ const updateSearch = (ev: Event) => {
|
||||
pendingRouteUpdate = null
|
||||
let p = loc
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (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)
|
||||
@@ -96,41 +114,78 @@ const settingsMenu = (e: Event) => {
|
||||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({
|
||||
label: '👤 ' + (store.user.username || 'User Account'),
|
||||
onClick: () => { window.location.href = '/auth/' }
|
||||
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' }})
|
||||
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' }})
|
||||
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: '🔐 Login',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
}
|
||||
}
|
||||
}})
|
||||
})
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
|
||||
items,
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
// @ts-ignore
|
||||
y: e.target.getBoundingClientRect().bottom,
|
||||
items
|
||||
})
|
||||
}
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
clearSearch,
|
||||
clearSearch
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateTextInputFocused()
|
||||
window.addEventListener('focusin', updateTextInputFocused)
|
||||
window.addEventListener('focusout', updateTextInputFocused)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('focusin', updateTextInputFocused)
|
||||
window.removeEventListener('focusout', updateTextInputFocused)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -141,6 +196,7 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
min-height: 3em;
|
||||
}
|
||||
.search-group {
|
||||
position: relative;
|
||||
@@ -158,6 +214,9 @@ defineExpose({
|
||||
.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);
|
||||
@@ -209,4 +268,34 @@ defineExpose({
|
||||
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>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div class="selection-bar" v-if="store.selected.size">
|
||||
<p class="select-text">{{ store.selected.size }} selected</p>
|
||||
<DownloadButton />
|
||||
<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"
|
||||
>❌<CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
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 op = (opName: string, dst?: string) => {
|
||||
const sel = store.selectedFiles
|
||||
const paths = sel.keys.map(key => {
|
||||
const doc = sel.docs[key]!
|
||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
})
|
||||
const msg = {
|
||||
op: opName,
|
||||
sel: paths
|
||||
}
|
||||
// @ts-ignore
|
||||
if (dst !== undefined) msg.dst = dst
|
||||
// Hide items being deleted or moved (optimistic update)
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.hideDoc(path)
|
||||
}
|
||||
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
|
||||
// Restore hidden items on error
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.unhideDoc(path)
|
||||
}
|
||||
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>
|
||||
.selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3em 0.5em;
|
||||
background: transparent;
|
||||
color: var(--header-color);
|
||||
}
|
||||
.select-text {
|
||||
color: var(--accent-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<SpinnerIcon />
|
||||
</div>
|
||||
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
||||
<img v-else-if=doc.img :src=doc.url alt="">
|
||||
<span v-else-if=doc.dir class="folder icon"></span>
|
||||
<div v-else-if=video() class="video-container">
|
||||
<video ref=vid :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">
|
||||
@@ -13,19 +17,23 @@
|
||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { computed, ref } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { Play as PlayIcon } from '@/assets/svg'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
const media = computed(() => aud.value || vid.value)
|
||||
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewSrc = computed(() =>
|
||||
props.doc.previewurl
|
||||
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const onplay = () => {
|
||||
if (!media.value) return
|
||||
@@ -37,11 +45,21 @@ const onpaused = () => {
|
||||
media.value.controls = false
|
||||
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
|
||||
const next = () => {
|
||||
if (!media.value) return
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (
|
||||
| HTMLAudioElement
|
||||
| HTMLVideoElement
|
||||
)[]
|
||||
if (medias.length === 0) return
|
||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||
for (const i in medias) {
|
||||
@@ -51,28 +69,32 @@ const next = () => {
|
||||
}
|
||||
}
|
||||
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
|
||||
// because we are not allowed to fullscreen the next one.
|
||||
// FIXME: Write our own player to avoid this problem...
|
||||
const elem = media.value as HTMLVideoElement
|
||||
const playing = el as HTMLVideoElement
|
||||
if (elem === playing) {
|
||||
playing.play() // Only one video, just replay
|
||||
playing.play() // Only one video, just replay
|
||||
return
|
||||
}
|
||||
if (!fscurrent) {
|
||||
elem.addEventListener('fullscreenchange', ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
elem.poster = poster.value
|
||||
onpaused()
|
||||
}, {once: true})
|
||||
elem.addEventListener(
|
||||
'fullscreenchange',
|
||||
ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
fscurrent = playing
|
||||
elem.src = playing.src
|
||||
@@ -88,7 +110,10 @@ defineExpose({
|
||||
if (!media.value) return false
|
||||
if (media.value.paused) {
|
||||
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
|
||||
el.pause()
|
||||
}
|
||||
@@ -97,18 +122,67 @@ defineExpose({
|
||||
}
|
||||
return true
|
||||
},
|
||||
media,
|
||||
media
|
||||
})
|
||||
|
||||
|
||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||
const preview = () => (
|
||||
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
||||
props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||
)
|
||||
const archive = () =>
|
||||
['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||
const preview = () =>
|
||||
[
|
||||
'bmp',
|
||||
'ico',
|
||||
'tif',
|
||||
'tiff',
|
||||
'heic',
|
||||
'heif',
|
||||
'pdf',
|
||||
'epub',
|
||||
'mobi',
|
||||
// Documents
|
||||
'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'
|
||||
].includes(props.doc.ext) ||
|
||||
(props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -120,6 +194,29 @@ img, embed, .icon, audio, video {
|
||||
max-height: 100%;
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.preview-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.preview-progress :deep(svg) {
|
||||
width: 4.5em;
|
||||
height: 4.5em;
|
||||
opacity: 0.8;
|
||||
animation: media-preview-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes media-preview-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.folder::before {
|
||||
content: '📁';
|
||||
}
|
||||
@@ -175,9 +272,14 @@ img::before {
|
||||
align-items: center;
|
||||
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%;
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { nextTick, ref, watchEffect } from 'vue'
|
||||
|
||||
const overlay = ref<HTMLDivElement | null>(null)
|
||||
const dialog = ref<HTMLDivElement | null>(null)
|
||||
@@ -27,9 +27,9 @@ const close = () => {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
name: typeof store.dialog,
|
||||
}>()
|
||||
title: string
|
||||
name: typeof store.dialog
|
||||
}>()
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { useMainStore } from '@/stores/main'
|
||||
<script setup lang="ts">
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
|
||||
</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 />
|
||||
<button
|
||||
class="action-button"
|
||||
title="Copy share link (Alt-click for read/write)"
|
||||
@click="copyShareLink"
|
||||
>share</button>
|
||||
<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"
|
||||
>✖ selection</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>
|
||||
@@ -44,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="action-button"
|
||||
:tabindex="tabindex"
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
@@ -12,13 +13,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { icons, type IconName } from '@/assets/svg'
|
||||
import { type IconName, icons } from '@/assets/svg'
|
||||
import { ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: IconName
|
||||
tooltip?: string
|
||||
tabindex?: string | number
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineEmits(['cancel'])
|
||||
|
||||
@@ -38,16 +38,17 @@ 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(() => {
|
||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||
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
|
||||
return s // "Current speed"
|
||||
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
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,12 +8,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils';
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -29,6 +28,22 @@ type CloudFile = {
|
||||
cloudName: string
|
||||
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
|
||||
function pasteHandler(event: ClipboardEvent) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
const infiles = [] as File[]
|
||||
@@ -46,7 +61,9 @@ function pasteHandler(event: ClipboardEvent) {
|
||||
if (infiles.length || dirs.length) {
|
||||
event.preventDefault()
|
||||
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) => {
|
||||
@@ -56,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||
for (const entry of entries) {
|
||||
const cloudName = `${loc}/${entry.name}`
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise(resolve => entry.file(resolve)) as File
|
||||
cloudfiles.push({file, cloudName, cloudPos: 0})
|
||||
const file = (await new Promise(resolve => entry.file(resolve))) as File
|
||||
cloudfiles.push({ file, cloudName, cloudPos: 0 })
|
||||
} else if (entry.isDirectory) {
|
||||
await pasteDirectory(entry, cloudName)
|
||||
}
|
||||
@@ -68,7 +85,9 @@ function uploadHandler(event: Event) {
|
||||
event.preventDefault()
|
||||
// @ts-ignore
|
||||
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 (infiles.length) uploadFiles(infiles)
|
||||
}
|
||||
@@ -82,8 +101,8 @@ const uploadFiles = (infiles: File[]) => {
|
||||
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||
files.push({
|
||||
file,
|
||||
cloudName: loc + '/' + relPath,
|
||||
cloudPos: 0,
|
||||
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||
cloudPos: 0
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
@@ -115,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
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, mtime: now, dir: true }))
|
||||
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, mtime: now, dir: false }))
|
||||
if (!existing)
|
||||
store.addGhost(
|
||||
new Doc({
|
||||
loc,
|
||||
name,
|
||||
key: crypto.randomUUID(),
|
||||
size: f.file.size,
|
||||
allocated: 0,
|
||||
mtime: now,
|
||||
dir: false
|
||||
})
|
||||
)
|
||||
}
|
||||
// @ts-ignore
|
||||
upqueue = [...upqueue, ...files]
|
||||
@@ -130,7 +170,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
}
|
||||
|
||||
const cancelUploads = () => {
|
||||
uploadRunId += 1
|
||||
upqueue = []
|
||||
blockQueue = []
|
||||
inflightBlocks.clear()
|
||||
uploadedBytes.clear()
|
||||
store.uprogress.status = 'idle'
|
||||
statReset()
|
||||
}
|
||||
|
||||
@@ -148,30 +193,106 @@ const uprogress_init = {
|
||||
filename: '',
|
||||
filesize: 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(() => {
|
||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
||||
// Reset
|
||||
const now = Date.now()
|
||||
const delta = refreshProgress(now)
|
||||
if (delta > 0) return
|
||||
if (now - store.uprogress.tlast > 3000) {
|
||||
store.uprogress.statbytes = 0
|
||||
store.uprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.uprogress.statbytes *= .9
|
||||
store.uprogress.statdur *= .9
|
||||
store.uprogress.statbytes *= 0.95
|
||||
store.uprogress.statdur *= 0.95
|
||||
}
|
||||
}, 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 now = Date.now()
|
||||
store.uprogress.xfer = store.uprogress.filestart + end
|
||||
store.uprogress.filepos = end
|
||||
store.uprogress.statbytes += end - start
|
||||
store.uprogress.statdur += now - store.uprogress.tlast
|
||||
store.uprogress.tlast = now
|
||||
// File finished?
|
||||
if (end === size) {
|
||||
|
||||
const statUpdate = ({ name, size, start, end }: UploadRange) => {
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
|
||||
// Track which bytes have been uploaded (using start to end range)
|
||||
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||
const uploaded = uploadedBytes.get(name)!
|
||||
const blockSize = UPLOAD_BLOCK_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
|
||||
statNextFile()
|
||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||
@@ -183,11 +304,15 @@ const statNextFile = () => {
|
||||
store.uprogress.filepos = 0
|
||||
store.uprogress.filesize = f.file.size
|
||||
store.uprogress.filename = f.cloudName
|
||||
lastVisualUploaded = 0
|
||||
lastProgressTick = Date.now()
|
||||
}
|
||||
const statReset = () => {
|
||||
Object.assign(store.uprogress, uprogress_init)
|
||||
store.uprogress.t0 = Date.now()
|
||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||
lastVisualUploaded = 0
|
||||
lastProgressTick = store.uprogress.t0
|
||||
}
|
||||
const statsAdd = (f: CloudFile[]) => {
|
||||
if (store.uprogress.files.length === 0) statReset()
|
||||
@@ -197,69 +322,204 @@ const statsAdd = (f: CloudFile[]) => {
|
||||
statNextFile()
|
||||
}
|
||||
let upqueue = [] as CloudFile[]
|
||||
const MAX_PARALLEL_REQUESTS = 4
|
||||
const RETRY_DELAY_MS = 400
|
||||
|
||||
// TODO: Rewrite as WebSocket class
|
||||
const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
const ws = connect(uploadUrl, {
|
||||
open(ev: Event) { resolve(ws) },
|
||||
error(ev: Event) {
|
||||
console.error('Upload socket error', ev)
|
||||
store.error = 'Upload socket error'
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev!.data)
|
||||
if ('error' in res) {
|
||||
console.error('Upload socket error', res.error)
|
||||
store.error = res.error.message
|
||||
return
|
||||
}
|
||||
if (res.status === 'ack') {
|
||||
statUpdate(res.req)
|
||||
} else console.log('Unknown upload response', res)
|
||||
},
|
||||
})
|
||||
// @ts-ignore
|
||||
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg))
|
||||
// @ts-ignore
|
||||
ws.sendData = async (data: any) => {
|
||||
// Wait until the WS is ready to send another message
|
||||
store.uprogress.status = "uploading"
|
||||
await new Promise(resolve => {
|
||||
const t = setInterval(() => {
|
||||
if (ws.bufferedAmount > 1<<20) return
|
||||
resolve(undefined)
|
||||
clearInterval(t)
|
||||
}, 1)
|
||||
})
|
||||
store.uprogress.status = "processing"
|
||||
ws.send(data)
|
||||
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
|
||||
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const FINAL_BLOCKS_COUNT = 2
|
||||
|
||||
const fileSize = file.file.size
|
||||
const blocks: { start: number; end: number }[] = []
|
||||
|
||||
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||
// File is large enough, prioritize final blocks
|
||||
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
|
||||
|
||||
// Add final blocks first
|
||||
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||
const start = finalBlocksStart + i * BLOCK_SIZE
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
|
||||
// Add remaining blocks from beginning
|
||||
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
} else {
|
||||
// File is smaller, use sequential upload
|
||||
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
}
|
||||
})
|
||||
const worker = async () => {
|
||||
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
|
||||
|
||||
return blocks
|
||||
}
|
||||
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 = () => {
|
||||
if (workerRunning === false) workerRunning = setTimeout(() => {
|
||||
workerRunning = true
|
||||
worker()
|
||||
if (workerRunning) return
|
||||
workerRunning = true
|
||||
const runId = uploadRunId
|
||||
setTimeout(() => {
|
||||
void worker(runId)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
<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
|
||||
@@ -61,10 +74,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
||||
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
|
||||
@@ -78,9 +99,12 @@ const users = ref<User[]>([])
|
||||
const success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false
|
||||
public: false,
|
||||
name: ''
|
||||
})
|
||||
|
||||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const close = () => {
|
||||
store.dialog = ''
|
||||
success.value = ''
|
||||
@@ -147,10 +171,13 @@ const renameUser = async (user: User) => {
|
||||
}
|
||||
|
||||
const resetPassword = async (user: User) => {
|
||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||
if (
|
||||
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
|
||||
)
|
||||
return
|
||||
try {
|
||||
success.value = ''
|
||||
const result = await updateUser(user.username, { password: "" })
|
||||
const result = await updateUser(user.username, { password: '' })
|
||||
if (result.password) {
|
||||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||
}
|
||||
@@ -172,14 +199,17 @@ const deleteUserAction = async (username: string) => {
|
||||
}
|
||||
|
||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||
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 and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
||||
// 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(() => {
|
||||
@@ -206,23 +236,71 @@ const updateServerSettings = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
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 when dialog opens (only in built-in auth mode)
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'usermgmt' && !store.server.paskia) {
|
||||
loadUsers()
|
||||
// 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
|
||||
})
|
||||
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 { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||
import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
|
||||
|
||||
// Type for API error responses
|
||||
interface ApiError {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatSize, formatUnixDate } from "@/utils"
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
|
||||
export type FUID = string
|
||||
|
||||
@@ -7,38 +7,57 @@ export type DocProps = {
|
||||
name: string
|
||||
key: FUID
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
public loc: string = ""
|
||||
public key: FUID = ""
|
||||
public loc: string = ''
|
||||
public key: FUID = ''
|
||||
public size: number = 0
|
||||
public allocated: number = 0
|
||||
public mtime: number = 0
|
||||
public dir: boolean = false
|
||||
public ghost: boolean = false
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ""
|
||||
public _name: string = ''
|
||||
|
||||
constructor(props: Partial<DocProps> = {}) {
|
||||
const { name, ...rest } = props
|
||||
Object.assign(this, rest)
|
||||
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||
}
|
||||
get name() {
|
||||
return this._name
|
||||
}
|
||||
get name() { return this._name }
|
||||
set name(name: string) {
|
||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||
this._name = name
|
||||
}
|
||||
get sizedisp(): string { return formatSize(this.size) }
|
||||
get modified(): string { return formatUnixDate(this.mtime) }
|
||||
get sizedisp(): string {
|
||||
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 {
|
||||
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 {
|
||||
return this.url.replace(/^\/#/, '')
|
||||
@@ -46,16 +65,78 @@ export class Doc {
|
||||
get img(): boolean {
|
||||
// Folders cannot be images
|
||||
if (this.dir) return false
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
||||
return [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'svg'
|
||||
].includes(this.ext)
|
||||
}
|
||||
get complete(): boolean {
|
||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||
}
|
||||
get previewable(): boolean {
|
||||
// Folders cannot be previewable
|
||||
if (this.dir) return false
|
||||
if (this.img) return true
|
||||
// Not a comprehensive list, but good enough for now
|
||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
||||
return [
|
||||
'mp4',
|
||||
'mkv',
|
||||
'webm',
|
||||
'ogg',
|
||||
'mp3',
|
||||
'flac',
|
||||
'aac',
|
||||
'pdf',
|
||||
// Documents
|
||||
'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'
|
||||
].includes(this.ext)
|
||||
}
|
||||
get previewurl(): string {
|
||||
if (!this.complete || !this.previewable) return ''
|
||||
return this.url.replace(/^\/files/, '/preview')
|
||||
}
|
||||
get ext(): string {
|
||||
@@ -75,12 +156,13 @@ export type errorEvent = {
|
||||
// Raw types the backend /api/watch sends us
|
||||
|
||||
export type FileEntry = [
|
||||
number, // level
|
||||
string, // name
|
||||
number, // level
|
||||
string, // name
|
||||
FUID,
|
||||
number, //mtime
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // isfile
|
||||
number, // allocated (actual disk usage)
|
||||
number // isfile
|
||||
]
|
||||
|
||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||
|
||||
@@ -16,7 +16,11 @@ export async function logoutUser() {
|
||||
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, {
|
||||
username,
|
||||
passwordChange,
|
||||
@@ -32,7 +36,11 @@ export async function listUsers() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password?: string, privileged?: boolean) {
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password?: string,
|
||||
privileged?: boolean
|
||||
) {
|
||||
const data = await Client.post(url_users, {
|
||||
username,
|
||||
password,
|
||||
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
|
||||
export async function updateUser(
|
||||
username: string,
|
||||
changes: { password?: string; privileged?: boolean }
|
||||
) {
|
||||
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||
return data
|
||||
}
|
||||
@@ -55,3 +66,41 @@ 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,9 +1,7 @@
|
||||
import { useMainStore } from "@/stores/main"
|
||||
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
import { useMainStore } from '@/stores/main'
|
||||
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'
|
||||
|
||||
let tree = [] as FileEntry[]
|
||||
@@ -27,18 +25,22 @@ export const loadSession = () => {
|
||||
console.log(`Loaded session with ${tree.length} items cached`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log("Loading session failed", error)
|
||||
console.log('Loading session failed', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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')))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function handleWsAuthError(msg: any) {
|
||||
// Stop reconnection attempts while showing auth dialog
|
||||
awaitingAuth = true
|
||||
store.authInProgress = true
|
||||
store.error = '' // Clear any connection message
|
||||
store.error = '' // Clear any connection message
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
@@ -91,9 +93,9 @@ export const watchConnect = () => {
|
||||
|
||||
wsWatch = connect(watchUrl, {
|
||||
message: handleWatchMessage,
|
||||
close: watchReconnect,
|
||||
close: watchReconnect
|
||||
})
|
||||
wsWatch.addEventListener("message", event => {
|
||||
wsWatch.addEventListener('message', event => {
|
||||
if (store.connected) return
|
||||
const msg = JSON.parse(event.data)
|
||||
if ('error' in msg) {
|
||||
@@ -105,7 +107,7 @@ export const watchConnect = () => {
|
||||
}
|
||||
return
|
||||
}
|
||||
if ("server" in msg) {
|
||||
if ('server' in msg) {
|
||||
console.log('Connected to backend', msg)
|
||||
store.server = msg.server
|
||||
store.connected = true
|
||||
@@ -143,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
|
||||
return
|
||||
}
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
console.warn('Disconnected from server', event)
|
||||
store.connected = false
|
||||
store.error = 'Reconnecting...'
|
||||
}
|
||||
@@ -153,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
|
||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||
}
|
||||
|
||||
|
||||
const handleWatchMessage = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
switch (true) {
|
||||
@@ -194,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
if (action === 'k') {
|
||||
newtree.push(...tree.slice(oidx, oidx + arg))
|
||||
oidx += arg
|
||||
}
|
||||
else if (action === 'd') oidx += arg
|
||||
} else if (action === 'd') oidx += 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)
|
||||
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)
|
||||
tree = newtree
|
||||
saveSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
|
||||
+59
-49
@@ -1,11 +1,11 @@
|
||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import type { FUID, FileEntry, SelectedItems } 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 { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||
import SearchWorker from '@/workers/searchWorker?worker'
|
||||
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||
import { type StateTree, defineStore } from 'pinia'
|
||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
||||
|
||||
// Singleton search worker instance
|
||||
let searchWorker: Worker | null = null
|
||||
@@ -19,8 +19,8 @@ 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
|
||||
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))
|
||||
@@ -34,7 +34,7 @@ function getSearchWorker(): Worker {
|
||||
// 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
|
||||
return // Skip intermediate update if too recent
|
||||
}
|
||||
lastResultUpdate = now
|
||||
|
||||
@@ -73,31 +73,33 @@ export const useMainStore = defineStore('main', {
|
||||
searchLoading: false,
|
||||
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
fileExplorer: null as any,
|
||||
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||
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,
|
||||
authInProgress: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
||||
server: {} as Record<string, any> & { public?: boolean; paskia?: boolean },
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
searchHotkey: '/' // Character shown for search hotkey (Slash key)
|
||||
},
|
||||
user: {
|
||||
username: '' as string,
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean,
|
||||
isLoggedIn: false as boolean
|
||||
},
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
usage: 0,
|
||||
used: 0,
|
||||
storage: 0,
|
||||
allocated: 0
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
@@ -112,29 +114,34 @@ export const useMainStore = defineStore('main', {
|
||||
tree.selected = Array.from(tree.selected)
|
||||
return JSON.stringify(tree)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push(new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
mtime,
|
||||
dir: !isfile,
|
||||
}))
|
||||
docs.push(
|
||||
new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile
|
||||
})
|
||||
)
|
||||
loc.push(name)
|
||||
}
|
||||
// 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))
|
||||
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)
|
||||
@@ -219,15 +226,16 @@ export const useMainStore = defineStore('main', {
|
||||
name: doc.name,
|
||||
key: doc.key,
|
||||
size: doc.size,
|
||||
allocated: doc.allocated,
|
||||
mtime: doc.mtime,
|
||||
dir: doc.dir,
|
||||
dir: doc.dir
|
||||
}))
|
||||
worker.postMessage({ type: 'update', documents: docData })
|
||||
},
|
||||
search(query: string, loc: string) {
|
||||
const worker = getSearchWorker()
|
||||
const id = ++searchId
|
||||
searchStore = this // Store reference for worker callback
|
||||
searchStore = this // Store reference for worker callback
|
||||
|
||||
// Update query immediately so watchers know we're handling this
|
||||
this.query = query
|
||||
@@ -260,7 +268,8 @@ export const useMainStore = defineStore('main', {
|
||||
|
||||
// Delay showing loading indicator to avoid flicker on fast searches
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (searchId === id) { // Still the current search
|
||||
if (searchId === id) {
|
||||
// Still the current search
|
||||
this.searchLoading = true
|
||||
}
|
||||
loadingTimer = null
|
||||
@@ -292,7 +301,7 @@ export const useMainStore = defineStore('main', {
|
||||
this.cursor = ''
|
||||
},
|
||||
async logout() {
|
||||
console.log("Logout")
|
||||
console.log('Logout')
|
||||
try {
|
||||
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
@@ -322,25 +331,29 @@ export const useMainStore = defineStore('main', {
|
||||
showSortToast(order: SortOrder | '') {
|
||||
const labels: Record<string, string> = {
|
||||
'': 'Folders first',
|
||||
'name': 'Alphabetical order',
|
||||
'modified': 'Newest first',
|
||||
'size': 'Largest first',
|
||||
name: 'Alphabetical order',
|
||||
modified: 'Newest first',
|
||||
size: 'Largest first'
|
||||
}
|
||||
this.showToast(labels[order] || order, 1200)
|
||||
},
|
||||
focusBreadcrumb() {
|
||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
},
|
||||
cancelDownloads() {
|
||||
location.reload() // FIXME
|
||||
location.reload() // FIXME
|
||||
},
|
||||
cancelUploads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
location.reload() // FIXME
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||
sortOrder(): SortOrder {
|
||||
return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||
},
|
||||
isUserLogged(): boolean {
|
||||
return this.user.isLoggedIn
|
||||
},
|
||||
/** Get documents count (triggers on docVersion change) */
|
||||
documentCount(): number {
|
||||
// Access docVersion to make this reactive
|
||||
@@ -362,7 +375,7 @@ export const useMainStore = defineStore('main', {
|
||||
missing: new Set(),
|
||||
docs: {},
|
||||
keys: [],
|
||||
recursive: [],
|
||||
recursive: []
|
||||
}
|
||||
for (const doc of docs) {
|
||||
if (selected.has(doc.key)) {
|
||||
@@ -374,22 +387,19 @@ export const useMainStore = defineStore('main', {
|
||||
// What did we not select?
|
||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||
// Build a flat list including contents recursively
|
||||
const relnames = new Set<string>()
|
||||
function add(rel: string, full: string, doc: 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) {
|
||||
const base = ret.docs[key]!
|
||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||
const nremove = base.loc.length
|
||||
add(base.name, basepath, base)
|
||||
ret.recursive.push([base.name, basepath, base])
|
||||
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 rel = full.slice(nremove)
|
||||
add(rel, full, doc)
|
||||
ret.recursive.push([rel, full, doc])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clearTree } from '@/repositories/WS'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useMainStore } from './main'
|
||||
import { clearTree } from '@/repositories/WS'
|
||||
|
||||
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||
const isExternalAuth = computed(() => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
export const exists = (path: string[]) => {
|
||||
const store = useMainStore()
|
||||
// Access docVersion to make this reactive
|
||||
void store.docVersion
|
||||
const p = path.join('/')
|
||||
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||
return getDocuments().some(
|
||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
||||
)
|
||||
}
|
||||
|
||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||
|
||||
+52
-23
@@ -26,26 +26,39 @@ export function formatUnixDate(t: number) {
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
if (adiff <= 5000) return 'now'
|
||||
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) {
|
||||
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) {
|
||||
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
|
||||
return formatter
|
||||
.format(Math.round(diff / 3600000), 'hour')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
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', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).replace("Sept", "Sep")
|
||||
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
|
||||
let d = date
|
||||
.toLocaleDateString('en-ie', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
.replace('Sept', 'Sep')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,34 +76,50 @@ interface FileTypes {
|
||||
const filetypes: FileTypes = {
|
||||
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
||||
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
pdf: ['pdf'],
|
||||
pdf: ['pdf']
|
||||
}
|
||||
|
||||
export function getFileType(name: string): string {
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||
return (
|
||||
Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
// Prebuilt for fast & consistent sorting
|
||||
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||
export const collator = new Intl.Collator('en', {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
usage: 'search'
|
||||
})
|
||||
|
||||
// Preformat document names for faster search
|
||||
export function haystackFormat(str: string) {
|
||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
const based = str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
return '^' + based + '$'
|
||||
}
|
||||
|
||||
|
||||
// Preformat search string for faster search
|
||||
export function needleFormat(query: string) {
|
||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return {based, words: based.split(/\s+/)}
|
||||
const based = query
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
return { based, words: based.split(/\s+/) }
|
||||
}
|
||||
|
||||
// Test if haystack includes needle
|
||||
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
|
||||
const {based, words} = filter
|
||||
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
|
||||
export function localeIncludes(
|
||||
haystack: string,
|
||||
filter: { based: string; words: string[] }
|
||||
) {
|
||||
const { based, words } = filter
|
||||
return (
|
||||
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed, watch } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const fileExplorer = ref()
|
||||
@@ -40,7 +40,7 @@ const folderPath = computed(() => props.path.join('/'))
|
||||
watch(
|
||||
() => [props.query, props.path.join('/')] as const,
|
||||
([query, loc]) => {
|
||||
if (store.query === query) return // Already searching this query
|
||||
if (store.query === query) return // Already searching this query
|
||||
store.search(query, loc)
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -55,9 +55,14 @@ const documents = computed(() => {
|
||||
// Access docVersion to make this reactive to document changes
|
||||
void store.docVersion
|
||||
const hidden = store.hiddenPaths
|
||||
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||
const docs = getDocuments().filter(
|
||||
doc =>
|
||||
doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
|
||||
)
|
||||
// Overlay ghosts for this location (excluding hidden ones)
|
||||
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||
const ghosts = store.ghosts.filter(
|
||||
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))]
|
||||
@@ -66,7 +71,9 @@ const documents = computed(() => {
|
||||
|
||||
// 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))
|
||||
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
|
||||
@@ -81,11 +88,15 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
// 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 })
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface DocData {
|
||||
name: string
|
||||
key: string
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
}
|
||||
@@ -36,14 +37,14 @@ interface ResultMessage {
|
||||
}
|
||||
|
||||
// Worker state
|
||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||
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
|
||||
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
|
||||
@@ -52,11 +53,21 @@ 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() + '$'
|
||||
return (
|
||||
'^' +
|
||||
str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase() +
|
||||
'$'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeQuery(str: string): string {
|
||||
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// Test if document matches search query
|
||||
@@ -142,8 +153,12 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
|
||||
// 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
|
||||
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)
|
||||
@@ -174,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
}
|
||||
|
||||
// Post results to main thread
|
||||
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
|
||||
function postResults(
|
||||
docs: WorkerDoc[],
|
||||
query: string,
|
||||
loc: string,
|
||||
id: number,
|
||||
done: boolean
|
||||
) {
|
||||
const sorted = sortResults(docs, query, loc)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
@@ -187,20 +208,21 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
|
||||
// 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)
|
||||
))
|
||||
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
|
||||
@@ -219,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||
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)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: [],
|
||||
id: msg.id,
|
||||
done: true
|
||||
} as ResultMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
|
||||
*/
|
||||
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
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,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
name: 'fastapi-vite',
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../cista/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
outDir: '../cista/frontend-build',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+14
-16
@@ -1,29 +1,29 @@
|
||||
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 { defineConfig } from 'vite'
|
||||
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
// @ts-ignore
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
||||
fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
|
||||
vue(),
|
||||
svgLoader(), // import svg files
|
||||
Components(), // auto import components
|
||||
svgLoader(), // import svg files
|
||||
Components() // auto import components
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
javascriptEnabled: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -35,11 +35,9 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Bundle all SVG icons into a single chunk
|
||||
icons: [
|
||||
'/src/assets/svg/index.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
icons: ['/src/assets/svg/index.ts']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+55
-5
@@ -41,10 +41,12 @@ dependencies = [
|
||||
"pillow-heif>=1.1.0",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -113,7 +115,57 @@ filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # formatter compatibility
|
||||
"ISC001", # formatter compatibility
|
||||
"ANN001", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN002", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN003", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN201", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN202", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN204", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ARG001", # framework and callback signatures commonly require unused args
|
||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||
"C901", # legacy complexity; keep other correctness rules enabled
|
||||
"D100", # legacy docs not yet standardized
|
||||
"D101", # legacy docs not yet standardized
|
||||
"D102", # legacy docs not yet standardized
|
||||
"D103", # legacy docs not yet standardized
|
||||
"D104", # legacy docs not yet standardized
|
||||
"D105", # legacy docs not yet standardized
|
||||
"D107", # legacy docs not yet standardized
|
||||
"D200", # legacy docs not yet standardized
|
||||
"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
|
||||
"PLC0415", # lazy imports used to avoid startup/circular import issues
|
||||
"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", # legacy comparisons use inline constants
|
||||
"PLW0603", # module-level shared state exists in server runtime code
|
||||
"SLF001", # cohesive modules occasionally need private-member access
|
||||
"TRY002", # exception-class strictness too noisy on legacy handlers
|
||||
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||
"TRY004", # type-check strictness too noisy on legacy handlers
|
||||
"TRY300", # stylistic try/else preference
|
||||
"TRY301", # stylistic raise-in-try preference
|
||||
]
|
||||
isort.known-first-party = ["cista"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||
per-file-ignores."scripts/*" = ["T20"]
|
||||
@@ -121,15 +173,13 @@ per-file-ignores."scripts/*" = ["T20"]
|
||||
[dependency-groups]
|
||||
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",
|
||||
]
|
||||
test = [
|
||||
"pytest>=8.4.1",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
"sanic-testing>=24.6.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
@@ -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())
|
||||
+26
-16
@@ -2,11 +2,11 @@
|
||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||
|
||||
Usage:
|
||||
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
|
||||
uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
|
||||
|
||||
Options:
|
||||
frontend Vite frontend endpoint (default: localhost:8989)
|
||||
--backend Cista backend endpoint (default: from config, or :8999)
|
||||
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||
--backend Cista backend endpoint (default: from config, or :8999)
|
||||
|
||||
Any additional arguments are passed to the cista command.
|
||||
|
||||
@@ -23,7 +23,12 @@ from pathlib import Path
|
||||
|
||||
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
|
||||
from devutil import ( # type: ignore[import-not-found]
|
||||
ProcessGroup,
|
||||
logger,
|
||||
ready,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
from cista import config
|
||||
from cista.serve import parse_listen
|
||||
@@ -31,29 +36,35 @@ from cista.serve import parse_listen
|
||||
DEFAULT_BACKEND_PORT = 8999
|
||||
|
||||
|
||||
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
|
||||
def setup_sanic_backend(
|
||||
listen: str | None, extra_args: list[str]
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend listen address and build cista dev command.
|
||||
|
||||
Returns (url, cmd).
|
||||
"""
|
||||
config.load_config()
|
||||
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
|
||||
url, opts = parse_listen(listen)
|
||||
_url, opts = parse_listen(listen)
|
||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||
host = opts.get("host", "localhost") or "localhost"
|
||||
|
||||
cmd = ["cista", "--dev", "-l", listen] + extra_args
|
||||
# Use the current interpreter/module path so devserver always runs
|
||||
# workspace source code instead of a potentially stale installed script.
|
||||
cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen, *extra_args]
|
||||
return f"http://{host}:{port}", cmd
|
||||
|
||||
|
||||
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
|
||||
async def run_devserver(
|
||||
frontend: str | None, backend: str | None, extra_args: list[str]
|
||||
) -> None:
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", front)
|
||||
raise SystemExit(1)
|
||||
|
||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||
_frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
||||
|
||||
# Tell vite where to proxy API requests
|
||||
@@ -80,26 +91,25 @@ def main():
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"frontend",
|
||||
nargs="?",
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:8989)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
"-l",
|
||||
metavar="host:port",
|
||||
help="Cista backend endpoint (default: from config, or :8999)",
|
||||
)
|
||||
args, unknown = parser.parse_known_args()
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
|
||||
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
scripts/devserver.py # Default ports
|
||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||
scripts/devserver.py # Default ports
|
||||
scripts/devserver.py -l 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py -l :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||
|
||||
Additional arguments are passed to the cista backend command.
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
from hatchling.builders.hooks.plugin.interface import (
|
||||
BuildHookInterface, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
@@ -30,8 +30,11 @@ def _check_node_version(node_path: str) -> None:
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
result = subprocess.run( # noqa: S603
|
||||
[node_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
@@ -134,7 +137,7 @@ def find_dev_tool() -> list[str]:
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
@@ -176,16 +179,16 @@ def build(folder: str = "frontend") -> None:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
def run(cmd):
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(1) from e
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -58,10 +59,8 @@ class ProcessGroup:
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
# Wait for all to finish (with overall timeout)
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
@@ -74,10 +73,8 @@ class ProcessGroup:
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await p.wait()
|
||||
|
||||
|
||||
@@ -95,10 +92,10 @@ async def ready(url: str, path: str = "") -> None:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
except httpx.RequestError as e:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import argparse
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from cista.preview import process_image_with_timing
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate image previews for all files in a folder, one at a time.",
|
||||
)
|
||||
parser.add_argument("folder", type=Path, help="Folder to scan recursively")
|
||||
parser.add_argument(
|
||||
"--px",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Maximum preview dimension in pixels (default: 1024)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quality",
|
||||
type=int,
|
||||
default=60,
|
||||
help="AVIF quality passed to preview generation (default: 60)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_image_file(path: Path) -> bool:
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
return bool(mime_type and mime_type.startswith("image/"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
folder = args.folder.resolve()
|
||||
if not folder.is_dir():
|
||||
raise SystemExit(f"Not a directory: {folder}")
|
||||
|
||||
files = sorted(
|
||||
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
|
||||
)
|
||||
if not files:
|
||||
print(f"No image files found under {folder}")
|
||||
return 0
|
||||
|
||||
total_files = 0
|
||||
total_bytes = 0
|
||||
total_timing_slots: list[float] = []
|
||||
total_preview_ms: float = 0.0
|
||||
failures = 0
|
||||
|
||||
print(f"Scanning {folder}")
|
||||
print(f"Generating previews for {len(files)} image files")
|
||||
|
||||
for path in files:
|
||||
total_files += 1
|
||||
rel = path.relative_to(folder)
|
||||
try:
|
||||
preview, timing = process_image_with_timing(
|
||||
path,
|
||||
maxsize=args.px,
|
||||
quality=args.quality,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {rel} error={exc}")
|
||||
continue
|
||||
|
||||
total_bytes += len(preview)
|
||||
timings = timing.timings or []
|
||||
if len(total_timing_slots) < len(timings):
|
||||
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
|
||||
for i, value in enumerate(timings):
|
||||
total_timing_slots[i] += value
|
||||
total_ms = sum(timings)
|
||||
total_preview_ms += total_ms
|
||||
|
||||
detail = " / ".join(f"{value:.1f}ms" for value in timings)
|
||||
if detail:
|
||||
detail = f"timings={detail} total={total_ms:.1f}ms"
|
||||
else:
|
||||
detail = f"total={total_ms:.1f}ms"
|
||||
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
|
||||
|
||||
completed = total_files - failures
|
||||
print()
|
||||
print("Summary")
|
||||
print(f" files={total_files}")
|
||||
print(f" completed={completed}")
|
||||
print(f" failed={failures}")
|
||||
print(f" preview_bytes={total_bytes}")
|
||||
if completed:
|
||||
if total_timing_slots:
|
||||
for i, value in enumerate(total_timing_slots, start=1):
|
||||
print(f" timing{i}_total_ms={value:.1f}")
|
||||
print(f" preview_total_ms={total_preview_ms:.1f}")
|
||||
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,53 +0,0 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cista import config
|
||||
from cista.protocol import Cp, MkDir, Mv, Rename, Rm
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def setup_temp_dir():
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
config.config = config.Config(path=Path(tmpdirname), listen=":0")
|
||||
yield Path(tmpdirname)
|
||||
|
||||
|
||||
def test_mkdir(setup_temp_dir):
|
||||
cmd = MkDir(path="new_folder")
|
||||
cmd()
|
||||
assert (setup_temp_dir / "new_folder").is_dir()
|
||||
|
||||
|
||||
def test_rename(setup_temp_dir):
|
||||
(setup_temp_dir / "old_name").mkdir()
|
||||
cmd = Rename(path="old_name", to="new_name")
|
||||
cmd()
|
||||
assert not (setup_temp_dir / "old_name").exists()
|
||||
assert (setup_temp_dir / "new_name").is_dir()
|
||||
|
||||
|
||||
def test_rm(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_remove").mkdir()
|
||||
cmd = Rm(sel=["folder_to_remove"])
|
||||
cmd()
|
||||
assert not (setup_temp_dir / "folder_to_remove").exists()
|
||||
|
||||
|
||||
def test_mv(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_move").mkdir()
|
||||
(setup_temp_dir / "destination").mkdir()
|
||||
cmd = Mv(sel=["folder_to_move"], dst="destination")
|
||||
cmd()
|
||||
assert not (setup_temp_dir / "folder_to_move").exists()
|
||||
assert (setup_temp_dir / "destination" / "folder_to_move").is_dir()
|
||||
|
||||
|
||||
def test_cp(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_copy").mkdir()
|
||||
(setup_temp_dir / "destination").mkdir()
|
||||
cmd = Cp(sel=["folder_to_copy"], dst="destination")
|
||||
cmd()
|
||||
assert (setup_temp_dir / "folder_to_copy").is_dir()
|
||||
assert (setup_temp_dir / "destination" / "folder_to_copy").is_dir()
|
||||
@@ -0,0 +1,292 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import auth, config, session, watching
|
||||
from cista.app import use_session
|
||||
from cista.fileserver import bp as fileserver_bp
|
||||
|
||||
|
||||
def _basic_auth(username: str, password: str) -> dict[str, str]:
|
||||
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {creds}"}
|
||||
|
||||
|
||||
def _ntlm_type1() -> dict[str, str]:
|
||||
msg = b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0x20080205)
|
||||
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
|
||||
|
||||
|
||||
def _ntlm_type3(
|
||||
username: str, password: str, domain: str, challenge: bytes
|
||||
) -> dict[str, str]:
|
||||
"""Build an NTLMv2 Type 3 message for testing."""
|
||||
from Crypto.Hash import MD4
|
||||
|
||||
# NT hash
|
||||
nt_hash = MD4.new(password.encode("utf-16le")).digest()
|
||||
# NTLMv2 hash
|
||||
ntlmv2_hash = hmac.new(
|
||||
nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5
|
||||
).digest()
|
||||
|
||||
# Build a minimal blob
|
||||
timestamp = struct.pack("<Q", 0)
|
||||
client_nonce = b"\x01" * 8
|
||||
blob = (
|
||||
b"\x01\x01\x00\x00\x00\x00\x00\x00"
|
||||
+ timestamp
|
||||
+ client_nonce
|
||||
+ b"\x00\x00\x00\x00"
|
||||
)
|
||||
|
||||
# NT proof
|
||||
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
|
||||
nt_response = nt_proof + blob
|
||||
|
||||
domain_enc = domain.encode("utf-16le")
|
||||
username_enc = username.encode("utf-16le")
|
||||
workstation_enc = b""
|
||||
|
||||
lm_response = b"" # Empty for NTLMv2
|
||||
|
||||
# Build Type 3 message
|
||||
msg = bytearray()
|
||||
msg.extend(b"NTLMSSP\x00")
|
||||
msg.extend(struct.pack("<I", 3))
|
||||
|
||||
# Security buffers offsets will be calculated
|
||||
payload_start = 64
|
||||
payloads = []
|
||||
|
||||
def add_buf(data: bytes):
|
||||
offset = payload_start + sum(len(p) for p in payloads)
|
||||
payloads.append(data)
|
||||
return struct.pack("<HHI", len(data), len(data), offset)
|
||||
|
||||
lm_buf = add_buf(lm_response)
|
||||
nt_buf = add_buf(nt_response)
|
||||
domain_buf = add_buf(domain_enc)
|
||||
user_buf = add_buf(username_enc)
|
||||
ws_buf = add_buf(workstation_enc)
|
||||
session_buf = add_buf(b"")
|
||||
|
||||
msg.extend(lm_buf)
|
||||
msg.extend(nt_buf)
|
||||
msg.extend(domain_buf)
|
||||
msg.extend(user_buf)
|
||||
msg.extend(ws_buf)
|
||||
msg.extend(session_buf)
|
||||
msg.extend(struct.pack("<I", 0x20080205))
|
||||
for p in payloads:
|
||||
msg.extend(p)
|
||||
|
||||
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
|
||||
|
||||
|
||||
def _session_cookie_header(username: str) -> dict[str, str]:
|
||||
token = "test-" + username
|
||||
session._sessions[token] = {
|
||||
"exp": int(time()) + session.max_age,
|
||||
"username": username,
|
||||
}
|
||||
return {"Cookie": f"cista={token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
user = config.User()
|
||||
auth.set_password(user, "secret")
|
||||
token = config.Token(key="test_token_123", username="alice")
|
||||
share_ro = config.Token(
|
||||
key="share_ro_123",
|
||||
username="alice",
|
||||
kind="share",
|
||||
mode="ro",
|
||||
share_paths=["hello.txt", "docs"],
|
||||
)
|
||||
share_rw = config.Token(
|
||||
key="share_rw_123",
|
||||
username="alice",
|
||||
kind="share",
|
||||
mode="rw",
|
||||
share_paths=["docs"],
|
||||
)
|
||||
config.config = config.Config(
|
||||
path=tmp_path,
|
||||
listen=":0",
|
||||
public=False,
|
||||
users={"alice": user},
|
||||
tokens={
|
||||
"test_token_123": token,
|
||||
"share_ro_123": share_ro,
|
||||
"share_rw_123": share_rw,
|
||||
},
|
||||
)
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
|
||||
(tmp_path / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
|
||||
(tmp_path / "docs" / "b.txt").write_text("B", encoding="utf-8")
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
|
||||
@app.on_request
|
||||
async def load_auth_context(request):
|
||||
await use_session(request)
|
||||
|
||||
app.blueprint(fileserver_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_auth_allows_private_file_access(client):
|
||||
_, res = await client.get(
|
||||
"/files/hello.txt", headers=_basic_auth("alice", "secret")
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.body == b"hello"
|
||||
assert "set-cookie" not in res.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client):
|
||||
_, res = await client.get(
|
||||
"/files/hello.txt",
|
||||
headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")},
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_unauthenticated_allowed(client):
|
||||
_, res = await client.options("/files/")
|
||||
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_sends_basic_auth_challenge(client):
|
||||
_, res = await client.request("PROPFIND", "/files/")
|
||||
|
||||
assert res.status_code == 401
|
||||
assert (
|
||||
res.headers.get("www-authenticate", "")
|
||||
.lower()
|
||||
.startswith('basic realm="cista"')
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_auth_with_token(client):
|
||||
_, res = await client.get(
|
||||
"/files/hello.txt", headers=_basic_auth("token", "test_token_123")
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.body == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_unauthenticated_sends_cookie_challenge(client):
|
||||
_, res = await client.get(
|
||||
"/files/", headers={"Accept": "text/html,application/xhtml+xml"}
|
||||
)
|
||||
|
||||
assert res.status_code == 401
|
||||
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ntlm_auth_with_token(client):
|
||||
# Step 1: request without auth should NOT advertise NTLM
|
||||
# (we prefer clients use BASIC; NTLM still works if client initiates it)
|
||||
_, res1 = await client.get("/files/hello.txt")
|
||||
assert res1.status_code == 401
|
||||
assert "ntlm" not in res1.headers.get("www-authenticate", "").lower()
|
||||
|
||||
# Step 2: client proactively sends Type 1, gets Type 2 challenge
|
||||
_, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1())
|
||||
assert res2.status_code == 401
|
||||
auth_hdr = res2.headers.get("www-authenticate", "")
|
||||
assert auth_hdr.lower().startswith("ntlm ")
|
||||
type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1])
|
||||
challenge = type2_data[24:32]
|
||||
|
||||
# Step 3: send Type 3 with token as password
|
||||
_, res3 = await client.get(
|
||||
"/files/hello.txt",
|
||||
headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge),
|
||||
)
|
||||
assert res3.status_code == 200
|
||||
assert res3.body == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_token_limits_visible_paths(client):
|
||||
_, res = await client.get(
|
||||
"/files/docs/a.txt", headers=_basic_auth("token", "share_ro_123")
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.body == b"A"
|
||||
|
||||
_, res = await client.get(
|
||||
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.body == b"hello"
|
||||
|
||||
_, res = await client.get(
|
||||
"/files/secret.txt", headers=_basic_auth("token", "share_ro_123")
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_token_read_only_blocks_writes(client):
|
||||
_, res = await client.delete(
|
||||
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_token_rw_allows_writes_in_scope_only(client):
|
||||
_, res = await client.delete(
|
||||
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
|
||||
)
|
||||
assert res.status_code == 204
|
||||
|
||||
_, res = await client.get(
|
||||
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
_, res = await client.delete(
|
||||
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
|
||||
)
|
||||
assert res.status_code == 404
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Path traversal and percent-encoding security tests for the fileserver."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, watching
|
||||
from cista.fileserver import bp as fileserver_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
app.blueprint(fileserver_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# %2F — encoded slash should be decoded as a path separator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_percent2f_decoded_as_path_separator(client, setup_storage: Path):
|
||||
"""%2F in the URL path is decoded to '/' and treated as a path separator."""
|
||||
(setup_storage / "sub").mkdir()
|
||||
(setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8")
|
||||
|
||||
_, res = await client.get("/files/sub%2Ffile.txt")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.text == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_percent2f_creates_nested_directory(client, setup_storage: Path):
|
||||
"""%2F in MKCOL path is decoded as a separator, creating nested dirs."""
|
||||
_, res = await client.request("MKCOL", "/files/parent%2Fchild")
|
||||
|
||||
assert res.status_code == 201
|
||||
assert (setup_storage / "parent" / "child").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# %20 — encoded space in filename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_percent20_in_filename(client, setup_storage: Path):
|
||||
(setup_storage / "my file.txt").write_text("spaced", encoding="utf-8")
|
||||
|
||||
_, res = await client.get("/files/my%20file.txt")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.text == "spaced"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_percent20_in_folder_name(client, setup_storage: Path):
|
||||
_, res = await client.request("MKCOL", "/files/my%20folder")
|
||||
|
||||
assert res.status_code == 201
|
||||
assert (setup_storage / "my folder").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path traversal — .. and encoded variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dotdot_rejected(client):
|
||||
""".. is path-normalised by the router before reaching the handler."""
|
||||
_, res = await client.get("/files/..")
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dotdot_segment_rejected(client):
|
||||
"""Traversal via sub/../.. is path-normalised by the router."""
|
||||
_, res = await client.get("/files/sub/../..")
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_encoded_dotdot_rejected(client):
|
||||
"""%2E%2E (encoded ..) must be rejected."""
|
||||
_, res = await client.get("/files/%2E%2E")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_encoded_dotdot_segment_rejected(client):
|
||||
"""%2E%2E used as a segment in a longer path must be rejected."""
|
||||
_, res = await client.get("/files/sub%2F%2E%2E%2F..%2Fetc%2Fpasswd")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_dotdot_rejected(client):
|
||||
_, res = await client.request("MKCOL", "/files/..")
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dotdot_rejected(client):
|
||||
_, res = await client.delete("/files/..")
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dot-prefixed filenames (.hidden, ...)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hidden_file_rejected(client):
|
||||
"""Names starting with '.' are not allowed."""
|
||||
_, res = await client.get("/files/.hidden")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_hidden_folder_rejected(client):
|
||||
_, res = await client.request("MKCOL", "/files/.secret")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows-style drive paths (c:/) — safe on Linux, stays inside storage root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_windows_drive_path_stays_within_root(client, setup_storage: Path):
|
||||
"""A Windows-style drive path like 'c:/foo' is treated as a relative path
|
||||
on Linux and resolves safely inside the storage root."""
|
||||
_, res = await client.request("MKCOL", "/files/c:/secret")
|
||||
|
||||
# Either created inside the storage root (201) or sanitised away (400/404).
|
||||
# The important assertion: nothing was created outside the storage root.
|
||||
assert not (Path("/c:") / "secret").exists()
|
||||
assert not (Path("c:/secret")).exists()
|
||||
if res.status_code == 201:
|
||||
# Created safely inside tmp storage
|
||||
assert (setup_storage / "c:" / "secret").is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_backslash_in_path_sanitised(client, setup_storage: Path):
|
||||
"""Backslashes are replaced with dashes, not treated as path separators."""
|
||||
_, res = await client.request("MKCOL", "/files/foo\\..\\bar")
|
||||
|
||||
assert res.status_code in (201, 400)
|
||||
# Must not escape storage root
|
||||
assert not (setup_storage.parent / "bar").exists()
|
||||
@@ -0,0 +1,242 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, watching
|
||||
from cista.fileserver import bp as fileserver_bp
|
||||
from cista.protocol import FileEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
app.blueprint(fileserver_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkcol_creates_directory(client, setup_storage: Path):
|
||||
_, res = await client.request("MKCOL", "/files/new-folder")
|
||||
|
||||
assert res.status_code == 201
|
||||
assert (setup_storage / "new-folder").is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_file(client, setup_storage: Path):
|
||||
file_path = setup_storage / "delete-me.txt"
|
||||
file_path.write_text("hello", encoding="utf-8")
|
||||
|
||||
_, res = await client.delete("/files/delete-me.txt")
|
||||
|
||||
assert res.status_code == 204
|
||||
assert not file_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_mv_moves_keys_to_target(client, setup_storage: Path):
|
||||
(setup_storage / "target").mkdir()
|
||||
(setup_storage / "alpha.txt").write_text("alpha", encoding="utf-8")
|
||||
(setup_storage / "beta.txt").write_text("beta", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
|
||||
FileEntry(1, "alpha.txt", "k-alpha", 0, 5, 0, 1),
|
||||
FileEntry(1, "beta.txt", "k-beta", 0, 4, 0, 1),
|
||||
]
|
||||
|
||||
_, res = await client.post("/files/target?mv=k-alpha+k-beta")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json["status"] == "ack"
|
||||
assert not (setup_storage / "alpha.txt").exists()
|
||||
assert not (setup_storage / "beta.txt").exists()
|
||||
assert (setup_storage / "target" / "alpha.txt").is_file()
|
||||
assert (setup_storage / "target" / "beta.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_cp_copies_keys_to_target(client, setup_storage: Path):
|
||||
(setup_storage / "target").mkdir()
|
||||
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
|
||||
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
|
||||
]
|
||||
|
||||
_, res = await client.post("/files/target?cp=k-copy")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json["counts"] == {"cp": 1, "mv": 0}
|
||||
assert (setup_storage / "copy-me.txt").is_file()
|
||||
assert (setup_storage / "target" / "copy-me.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_cp_repeated_params_and_plus_form_are_equivalent(
|
||||
client,
|
||||
setup_storage: Path,
|
||||
):
|
||||
(setup_storage / "target").mkdir()
|
||||
(setup_storage / "one.txt").write_text("one", encoding="utf-8")
|
||||
(setup_storage / "two.txt").write_text("two", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
|
||||
FileEntry(1, "one.txt", "k-one", 0, 3, 0, 1),
|
||||
FileEntry(1, "two.txt", "k-two", 0, 3, 0, 1),
|
||||
]
|
||||
|
||||
_, res1 = await client.post("/files/target?cp=k-one&cp=k-two")
|
||||
|
||||
assert res1.status_code == 200
|
||||
assert (setup_storage / "target" / "one.txt").is_file()
|
||||
assert (setup_storage / "target" / "two.txt").is_file()
|
||||
|
||||
(setup_storage / "target" / "one.txt").unlink()
|
||||
(setup_storage / "target" / "two.txt").unlink()
|
||||
|
||||
_, res2 = await client.post("/files/target?cp=k-one+k-two")
|
||||
|
||||
assert res2.status_code == 200
|
||||
assert (setup_storage / "target" / "one.txt").is_file()
|
||||
assert (setup_storage / "target" / "two.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_mv_with_to_renames_single_key(
|
||||
client,
|
||||
setup_storage: Path,
|
||||
):
|
||||
(setup_storage / "dst").mkdir()
|
||||
(setup_storage / "old-name.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
|
||||
FileEntry(1, "old-name.txt", "k-old", 0, 1, 0, 1),
|
||||
]
|
||||
|
||||
_, res = await client.post("/files/dst/new-name.txt?mv=k-old")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert not (setup_storage / "old-name.txt").exists()
|
||||
assert (setup_storage / "dst" / "new-name.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_cp_single_key_to_file_path(client, setup_storage: Path):
|
||||
(setup_storage / "dst").mkdir()
|
||||
(setup_storage / "src.txt").write_text("copy", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "dst", "k-dst", 0, 0, 0, 0),
|
||||
FileEntry(1, "src.txt", "k-src", 0, 4, 0, 1),
|
||||
]
|
||||
|
||||
_, res = await client.post("/files/dst/copied.txt?cp=k-src")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert (setup_storage / "src.txt").is_file()
|
||||
assert (setup_storage / "dst" / "copied.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_supports_combined_cp_then_mv(client, setup_storage: Path):
|
||||
(setup_storage / "target").mkdir()
|
||||
(setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8")
|
||||
(setup_storage / "move-me.txt").write_text("move", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "target", "k-target", 0, 0, 0, 0),
|
||||
FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1),
|
||||
FileEntry(1, "move-me.txt", "k-move", 0, 4, 0, 1),
|
||||
]
|
||||
|
||||
_, res = await client.post("/files/target?cp=k-copy&mv=k-move")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json["counts"] == {"cp": 1, "mv": 1}
|
||||
assert (setup_storage / "copy-me.txt").is_file()
|
||||
assert not (setup_storage / "move-me.txt").exists()
|
||||
assert (setup_storage / "target" / "copy-me.txt").is_file()
|
||||
assert (setup_storage / "target" / "move-me.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_rejects_unknown_query_args(client):
|
||||
_, res = await client.post("/files/?cp=k1&wat=1")
|
||||
|
||||
assert res.status_code == 400
|
||||
assert "unknown query parameter" in res.json["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_requires_query_args(client):
|
||||
_, res = await client.post("/files/")
|
||||
|
||||
assert res.status_code == 400
|
||||
assert "no query arguments" in res.json["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage: Path):
|
||||
(setup_storage / "a.txt").write_text("a", encoding="utf-8")
|
||||
(setup_storage / "b.txt").write_text("b", encoding="utf-8")
|
||||
(setup_storage / "target.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "a.txt", "k-a", 0, 1, 0, 1),
|
||||
FileEntry(1, "b.txt", "k-b", 0, 1, 0, 1),
|
||||
FileEntry(1, "target.txt", "k-target", 0, 1, 0, 1),
|
||||
]
|
||||
|
||||
_, cp_res = await client.post("/files/target.txt?cp=k-a+k-b")
|
||||
_, mv_res = await client.post("/files/target.txt?mv=k-a+k-b")
|
||||
|
||||
assert cp_res.status_code == 400
|
||||
assert "existing directory" in cp_res.json["message"].lower()
|
||||
assert mv_res.status_code == 400
|
||||
assert "existing directory" in mv_res.json["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_rejects_directory_to_existing_file_target(
|
||||
client, setup_storage: Path
|
||||
):
|
||||
(setup_storage / "folder").mkdir()
|
||||
(setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8")
|
||||
(setup_storage / "existing.txt").write_text("e", encoding="utf-8")
|
||||
|
||||
watching.state.root = [
|
||||
FileEntry(1, "folder", "k-folder", 0, 0, 0, 0),
|
||||
FileEntry(2, "nested.txt", "k-nested", 0, 1, 0, 1),
|
||||
FileEntry(1, "existing.txt", "k-existing", 0, 1, 0, 1),
|
||||
]
|
||||
|
||||
_, cp_res = await client.post("/files/existing.txt?cp=k-folder")
|
||||
_, mv_res = await client.post("/files/existing.txt?mv=k-folder")
|
||||
|
||||
assert cp_res.status_code == 400
|
||||
assert "directory to an existing file" in cp_res.json["message"].lower()
|
||||
assert mv_res.status_code == 400
|
||||
assert "directory to an existing file" in mv_res.json["message"].lower()
|
||||
@@ -0,0 +1,106 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, watching
|
||||
from cista.fileserver import bp as fileserver_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
app.blueprint(fileserver_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_full_content(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.get("/files/hello.txt")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.body == b"hello world"
|
||||
assert res.headers.get("accept-ranges") == "bytes"
|
||||
assert res.headers.get("content-length") == "11"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_file_returns_headers_without_body(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.head("/files/hello.txt")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert not res.body
|
||||
assert res.headers.get("content-length") == "11"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_range_start_end(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=1-4"})
|
||||
|
||||
assert res.status_code == 206
|
||||
assert res.body == b"ello"
|
||||
assert res.headers.get("content-range") == "bytes 1-4/11"
|
||||
assert res.headers.get("content-length") == "4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_suffix_range(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=-5"})
|
||||
|
||||
assert res.status_code == 206
|
||||
assert res.body == b"world"
|
||||
assert res.headers.get("content-range") == "bytes 6-10/11"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_file_with_range(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.head("/files/hello.txt", headers={"Range": "bytes=0-4"})
|
||||
|
||||
assert res.status_code == 206
|
||||
assert not res.body
|
||||
assert res.headers.get("content-range") == "bytes 0-4/11"
|
||||
assert res.headers.get("content-length") == "5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_unsatisfiable_range_returns_416(client, setup_storage: Path):
|
||||
path = setup_storage / "hello.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
_, res = await client.get("/files/hello.txt", headers={"Range": "bytes=99-100"})
|
||||
|
||||
assert res.status_code == 416
|
||||
assert res.headers.get("content-range") == "bytes */11"
|
||||
@@ -0,0 +1,274 @@
|
||||
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, watching
|
||||
from cista.fileserver import bp as fileserver_bp
|
||||
|
||||
_DAV_NS = "DAV:"
|
||||
_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"files-dav-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, *_METHODS)
|
||||
app.blueprint(fileserver_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
def _dav(tag: str) -> str:
|
||||
return f"{{{_DAV_NS}}}{tag}"
|
||||
|
||||
|
||||
def _parse_multistatus(body: bytes) -> list[ET.Element]:
|
||||
root = ET.fromstring(body)
|
||||
assert root.tag == _dav("multistatus")
|
||||
return root.findall(_dav("response"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPTIONS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_advertises_dav_class(client):
|
||||
_, res = await client.options("/files/")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert "1" in res.headers.get("dav", "")
|
||||
assert "PROPFIND" in res.headers.get("allow", "")
|
||||
assert "COPY" in res.headers.get("allow", "")
|
||||
assert "MOVE" in res.headers.get("allow", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_without_trailing_slash(client):
|
||||
"""WebDAV clients (e.g. Windows) send OPTIONS /files without trailing slash."""
|
||||
_, res = await client.options("/files")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert "1" in res.headers.get("dav", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PROPFIND
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propfind_root_depth0(client, setup_storage: Path):
|
||||
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "0"})
|
||||
|
||||
assert res.status_code == 207
|
||||
responses = _parse_multistatus(res.body)
|
||||
assert len(responses) == 1
|
||||
href = responses[0].findtext(_dav("href"))
|
||||
assert href == "/files/"
|
||||
rt = responses[0].find(f".//{_dav('resourcetype')}/{_dav('collection')}")
|
||||
assert rt is not None, "Root should be a collection"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propfind_root_depth1_lists_children(client, setup_storage: Path):
|
||||
(setup_storage / "alpha.txt").write_text("a", encoding="utf-8")
|
||||
(setup_storage / "beta").mkdir()
|
||||
|
||||
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "1"})
|
||||
|
||||
assert res.status_code == 207
|
||||
responses = _parse_multistatus(res.body)
|
||||
hrefs = [r.findtext(_dav("href")) for r in responses]
|
||||
assert "/files/" in hrefs
|
||||
assert "/files/alpha.txt" in hrefs
|
||||
assert "/files/beta/" in hrefs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propfind_file_has_content_length(client, setup_storage: Path):
|
||||
(setup_storage / "data.txt").write_text("hello", encoding="utf-8")
|
||||
|
||||
_, res = await client.request("PROPFIND", "/files/data.txt", headers={"Depth": "0"})
|
||||
|
||||
assert res.status_code == 207
|
||||
responses = _parse_multistatus(res.body)
|
||||
cl = responses[0].findtext(f".//{_dav('getcontentlength')}")
|
||||
assert cl == "5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
|
||||
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "infinity"})
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propfind_missing_resource_returns_404(client):
|
||||
_, res = await client.request("PROPFIND", "/files/no-such-file.txt")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COPY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_file_to_new_path(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("copy me", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"COPY",
|
||||
"/files/src.txt",
|
||||
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert (setup_storage / "src.txt").is_file()
|
||||
assert (setup_storage / "dst.txt").read_text() == "copy me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_overwrites_existing_by_default(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("new", encoding="utf-8")
|
||||
(setup_storage / "dst.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"COPY",
|
||||
"/files/src.txt",
|
||||
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert (setup_storage / "dst.txt").read_text() == "new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_overwrite_false_returns_412(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
|
||||
(setup_storage / "dst.txt").write_text("y", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"COPY",
|
||||
"/files/src.txt",
|
||||
headers={
|
||||
"Destination": "http://localhost/files/dst.txt",
|
||||
"Overwrite": "F",
|
||||
},
|
||||
)
|
||||
|
||||
assert res.status_code == 412
|
||||
assert (setup_storage / "dst.txt").read_text() == "y"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_directory_recursively(client, setup_storage: Path):
|
||||
(setup_storage / "src").mkdir()
|
||||
(setup_storage / "src" / "child.txt").write_text("child", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"COPY",
|
||||
"/files/src",
|
||||
headers={"Destination": "http://localhost/files/dst"},
|
||||
)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert (setup_storage / "dst" / "child.txt").read_text() == "child"
|
||||
assert (setup_storage / "src" / "child.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_missing_parent_returns_409(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"COPY",
|
||||
"/files/src.txt",
|
||||
headers={"Destination": "http://localhost/files/nodir/dst.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MOVE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_renames_file(client, setup_storage: Path):
|
||||
(setup_storage / "old.txt").write_text("data", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"MOVE",
|
||||
"/files/old.txt",
|
||||
headers={"Destination": "http://localhost/files/new.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert not (setup_storage / "old.txt").exists()
|
||||
assert (setup_storage / "new.txt").read_text() == "data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_overwrites_existing(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
|
||||
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"MOVE",
|
||||
"/files/src.txt",
|
||||
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert not (setup_storage / "src.txt").exists()
|
||||
assert (setup_storage / "dst.txt").read_text() == "src"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_overwrite_false_returns_412(client, setup_storage: Path):
|
||||
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
|
||||
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"MOVE",
|
||||
"/files/src.txt",
|
||||
headers={
|
||||
"Destination": "http://localhost/files/dst.txt",
|
||||
"Overwrite": "F",
|
||||
},
|
||||
)
|
||||
|
||||
assert res.status_code == 412
|
||||
assert (setup_storage / "src.txt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_same_source_and_dest_is_noop(client, setup_storage: Path):
|
||||
(setup_storage / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
_, res = await client.request(
|
||||
"MOVE",
|
||||
"/files/file.txt",
|
||||
headers={"Destination": "http://localhost/files/file.txt"},
|
||||
)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert (setup_storage / "file.txt").is_file()
|
||||
@@ -12,19 +12,19 @@ def mock_open(key):
|
||||
|
||||
|
||||
def test_contains():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||
assert "key1" not in cache
|
||||
cache["key1"]
|
||||
assert "key1" in cache
|
||||
|
||||
|
||||
def test_getitem():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||
assert cache["key1"].content == "content-key1"
|
||||
|
||||
|
||||
def test_capacity():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||
item1 = cache["key1"]
|
||||
cache["key2"]
|
||||
cache["key3"]
|
||||
@@ -33,7 +33,7 @@ def test_capacity():
|
||||
|
||||
|
||||
def test_expiry():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=0.1)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=0.1)
|
||||
item = cache["key1"]
|
||||
sleep(0.2) # Wait for expiration
|
||||
cache.expire_items()
|
||||
@@ -42,7 +42,7 @@ def test_expiry():
|
||||
|
||||
|
||||
def test_close():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||
item = cache["key1"]
|
||||
cache.close()
|
||||
assert "key1" not in cache
|
||||
@@ -50,7 +50,7 @@ def test_close():
|
||||
|
||||
|
||||
def test_lru_mechanism():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||
item1 = cache["key1"]
|
||||
item2 = cache["key2"]
|
||||
cache["key1"] # Make key1 recently used
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import auth, config, watching
|
||||
from cista.api import bp as api_bp
|
||||
from cista.auth import bp as auth_bp
|
||||
|
||||
|
||||
def _persist_config():
|
||||
from pathlib import PurePath
|
||||
|
||||
import msgspec
|
||||
|
||||
def enc_hook(obj):
|
||||
if isinstance(obj, PurePath):
|
||||
return obj.as_posix()
|
||||
raise TypeError
|
||||
|
||||
raw = msgspec.to_builtins(config.config, enc_hook=enc_hook)
|
||||
config.conffile.write_bytes(msgspec.toml.encode(raw))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
os.environ["CISTA_HOME"] = str(tmp_path)
|
||||
config.init_confdir()
|
||||
user = config.User()
|
||||
auth.set_password(user, "secret")
|
||||
admin = config.User(privileged=True)
|
||||
auth.set_password(admin, "secret")
|
||||
config.config = config.Config(
|
||||
path=tmp_path,
|
||||
listen=":0",
|
||||
public=False,
|
||||
users={"alice": user, "admin": admin},
|
||||
)
|
||||
_persist_config()
|
||||
watching.state.root = []
|
||||
watching.rootpath = tmp_path
|
||||
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
|
||||
yield tmp_path
|
||||
watching.state.root = []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
app = Sanic(f"token-test-{uuid4().hex}", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
app.blueprint(auth_bp)
|
||||
app.blueprint(api_bp)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
def _basic_auth(username: str, password: str) -> str:
|
||||
return f"Basic {__import__('base64').b64encode(f'{username}:{password}'.encode()).decode()}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_crud(client):
|
||||
# Admin creates a token without specifying username (auto-assigned)
|
||||
_, res = await client.post(
|
||||
"/auth/tokens",
|
||||
json={"name": "test"},
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json
|
||||
assert "id" in data
|
||||
assert "key" in data
|
||||
assert data["username"] == "admin"
|
||||
assert data["name"] == "test"
|
||||
token_id = data["id"]
|
||||
token_key = data["key"]
|
||||
|
||||
# List tokens - admin sees only their own
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
tokens = res.json["tokens"]
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0]["id"] == token_id
|
||||
assert tokens[0]["username"] == "admin"
|
||||
|
||||
# Use token via Basic auth (token:<secret>)
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("token", token_key)},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# Delete token
|
||||
_, res = await client.delete(
|
||||
f"/auth/tokens/{token_id}",
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# List should be empty
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json["tokens"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_user_scoped(client):
|
||||
# Alice creates a token for herself (no username specified)
|
||||
_, res = await client.post(
|
||||
"/auth/tokens",
|
||||
json={"name": "alice-token"},
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
alice_token_id = res.json["id"]
|
||||
alice_token_key = res.json["key"]
|
||||
|
||||
# Admin creates a token for themselves
|
||||
_, res = await client.post(
|
||||
"/auth/tokens",
|
||||
json={"name": "admin-token"},
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
admin_token_id = res.json["id"]
|
||||
|
||||
# Alice lists tokens - sees only her own
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
tokens = res.json["tokens"]
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0]["id"] == alice_token_id
|
||||
assert tokens[0]["username"] == "alice"
|
||||
|
||||
# Admin lists tokens - sees only their own
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
tokens = res.json["tokens"]
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0]["id"] == admin_token_id
|
||||
assert tokens[0]["username"] == "admin"
|
||||
|
||||
# Alice cannot create a token for admin
|
||||
_, res = await client.post(
|
||||
"/auth/tokens",
|
||||
json={"username": "admin", "name": "impersonation"},
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
# Alice cannot delete admin's token
|
||||
_, res = await client.delete(
|
||||
f"/auth/tokens/{admin_token_id}",
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
# Alice can delete her own token
|
||||
_, res = await client.delete(
|
||||
f"/auth/tokens/{alice_token_id}",
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# Alice's token auth still works until deletion is processed
|
||||
# Verify token auth worked during the test
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("token", alice_token_key)},
|
||||
)
|
||||
# Token was deleted above, so this should now be unauthenticated
|
||||
# Actually the token key lookup will fail, and since there's no session fallback...
|
||||
# With auth header present but invalid, it should return 401
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_share_token(client):
|
||||
_, res = await client.post(
|
||||
"/api/share-tokens",
|
||||
json={"paths": ["hello.txt", "docs"], "mode": "ro", "name": "selection"},
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json
|
||||
assert data["kind"] == "share"
|
||||
assert data["mode"] == "ro"
|
||||
assert data["paths"] == ["hello.txt", "docs"]
|
||||
assert "token:" in data["url"]
|
||||
|
||||
_, res = await client.get(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
||||
assert len(share_tokens) == 1
|
||||
assert share_tokens[0]["mode"] == "ro"
|
||||
@@ -10,7 +10,9 @@ def decode(data: str):
|
||||
|
||||
# Helper function to create a list of FileEntry objects
|
||||
def f(count, start=0):
|
||||
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
|
||||
return [
|
||||
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
|
||||
]
|
||||
|
||||
|
||||
def test_identical_lists():
|
||||
@@ -35,8 +37,8 @@ def test_insertions():
|
||||
|
||||
|
||||
def test_insertion_at_end():
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||
new_list = [*old_list, newfile]
|
||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
Reference in New Issue
Block a user