Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4965a86b | ||
|
|
bd7291e9ef | ||
|
|
2fa52229cc | ||
|
|
2dea459d8f | ||
|
|
922069c603 | ||
|
|
5c7c7343ad | ||
|
|
593d16d8c5 | ||
|
|
20d8d317fa | ||
|
|
8d89c397a4 | ||
|
|
1bec73f4cd | ||
|
|
4dd1d4c7e6 | ||
|
|
84ef91a360 | ||
|
|
421d90e9c5 | ||
|
|
8b4e622aef | ||
|
|
13f32c57ab | ||
|
|
25a2a5f20c | ||
|
|
041090cce9 | ||
|
|
ec6db7b53f | ||
|
|
575df1214b | ||
|
|
1927c24053 | ||
|
|
6242c76be8 | ||
|
|
87a92838c2 | ||
|
|
8c93a4f2b5 | ||
|
|
eb5ff82de6 | ||
|
|
942b54d795 | ||
|
|
18ee0f3f56 | ||
|
|
f8b2c9494a | ||
|
|
9ac571463a | ||
|
|
d52b24372a | ||
|
|
742b3006c9 | ||
|
|
01a979c00a | ||
|
|
f1e16b7abe | ||
|
|
0ebff0ec17 | ||
|
|
c8ab06d864 | ||
|
|
d5ff7757c8 | ||
|
|
cd604eb10a | ||
|
|
abcf5d9940 |
@@ -1,6 +1,7 @@
|
|||||||
.*
|
.*
|
||||||
*.lock
|
*.lock
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.pre-commit-config.yaml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/cista/_version.py
|
/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
|
||||||
@@ -45,7 +45,7 @@ The server remembers its settings in the config folder (default `~/.local/share/
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
Cista supports two authentication modes, each supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
|
|
||||||
### Public Mode
|
### Public Mode
|
||||||
|
|
||||||
@@ -83,6 +83,30 @@ In Paskia mode:
|
|||||||
- Users with `cista:login` permission can access files
|
- Users with `cista:login` permission can access files
|
||||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
- 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
|
### 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.
|
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.
|
||||||
|
|||||||
+1
-3
@@ -1,3 +1 @@
|
|||||||
from cista._version import __version__
|
from cista._version import __version__ as __version__
|
||||||
|
|
||||||
__version__ # Public API
|
|
||||||
|
|||||||
+37
-37
@@ -5,7 +5,8 @@ from pathlib import Path
|
|||||||
from docopt import docopt
|
from docopt import docopt
|
||||||
|
|
||||||
import cista
|
import cista
|
||||||
from cista import app, config, droppy, serve, server80
|
from cista import app, config, droppy, onlyoffice, serve, server80
|
||||||
|
from cista.sso import PASKIA_BACKEND_URL
|
||||||
from cista.util import pwgen
|
from cista.util import pwgen
|
||||||
|
|
||||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||||
@@ -30,14 +31,11 @@ def create_startup_box(
|
|||||||
):
|
):
|
||||||
"""Create a framed startup box with server information."""
|
"""Create a framed startup box with server information."""
|
||||||
title = f"Cista {cista.__version__}"
|
title = f"Cista {cista.__version__}"
|
||||||
listen = unix if unix else url
|
listen = unix or url
|
||||||
location = f"{folder} @ {listen}"
|
location = f"{folder} @ {listen}"
|
||||||
lines = [title, location]
|
lines = [title, location]
|
||||||
# Auth line: Paskia <url> or Password, with optional Public suffix
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
if paskia_url:
|
auth_line = f"Auth: Paskia {paskia_url}" if paskia_url else "Auth: Password"
|
||||||
auth_line = f"Auth: Paskia {paskia_url}"
|
|
||||||
else:
|
|
||||||
auth_line = "Auth: Password"
|
|
||||||
if public:
|
if public:
|
||||||
auth_line += ", Public"
|
auth_line += ", Public"
|
||||||
lines.append(auth_line)
|
lines.append(auth_line)
|
||||||
@@ -49,48 +47,46 @@ def create_startup_box(
|
|||||||
|
|
||||||
# Build the box
|
# Build the box
|
||||||
box = [f"╭{'─' * inner_width}╮"]
|
box = [f"╭{'─' * inner_width}╮"]
|
||||||
for line in lines:
|
box.extend(f"│ {line:<{inner_width - 1}}│" for line in lines)
|
||||||
box.append(f"│ {line:<{inner_width - 1}}│")
|
|
||||||
box.append(f"╰{'─' * inner_width}╯")
|
box.append(f"╰{'─' * inner_width}╯")
|
||||||
return "\n".join(box) + "\n"
|
return "\n".join(box) + "\n"
|
||||||
|
|
||||||
|
|
||||||
banner = create_banner()
|
banner = create_banner()
|
||||||
|
|
||||||
doc = """\
|
_default_confdir = (
|
||||||
|
(Path(os.environ["XDG_CONFIG_HOME"]) / "cista").as_posix()
|
||||||
|
if os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
else (Path.home() / ".config/cista").as_posix()
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = f"""\
|
||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
|
cista [-c <confdir>] --oosetup
|
||||||
cista --version
|
cista --version
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c CONFDIR Custom config directory
|
-c CONFDIR Config directory [{_default_confdir}]
|
||||||
-l, --listen LISTEN-ADDR
|
-l, --listen ADDR Listen on address (port, :port, /socket or domain for https)
|
||||||
Listen on
|
--import-droppy Import Droppy config from ~/.droppy/config
|
||||||
:8989 (localhost port, plain http)
|
--dev Developer mode (reloads, friendlier crashes, more logs)
|
||||||
<addr>:3000 (bind another address, port)
|
--user NAME Create or modify a user account (when server is not running)
|
||||||
/path/to/unix.sock (unix socket)
|
--privileged Grant admin rights
|
||||||
example.com (run on 80 and 443 with LetsEncrypt)
|
--password Reset password
|
||||||
--import-droppy Import Droppy config from ~/.droppy/config
|
--oosetup Build and run OnlyOffice in Docker for document previews
|
||||||
--dev Developer mode (reloads, friendlier crashes, more logs)
|
|
||||||
|
|
||||||
Listen address and path are preserved in config,
|
|
||||||
and only config dir and dev mode need to be specified on subsequent runs.
|
|
||||||
|
|
||||||
User management:
|
|
||||||
--user NAME Create or modify user
|
|
||||||
--privileged Give the user full admin rights
|
|
||||||
--password Reset password
|
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
||||||
https://git.zi.fi/leovasanko/paskia
|
https://git.zi.fi/leovasanko/paskia
|
||||||
|
ONLYOFFICE_CISTA_URL, ONLYOFFICE_JWT_SECRET, ONLYOFFICE_CALLBACK_HOST (if needed)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
first_time_help = """\
|
first_time_help = """\
|
||||||
No config file found! Get started with:
|
No config file found! Get started with:
|
||||||
cista --user yourname --privileged # If you want user accounts
|
cista --user yourname --privileged # If you want user accounts
|
||||||
cista -l :8989 /path/to/files # Run the server on localhost:8989
|
cista -l :8989 /path/to/files # Run the server on localhost:8989
|
||||||
|
|
||||||
See cista --help for other options!
|
See cista --help for other options!
|
||||||
"""
|
"""
|
||||||
@@ -119,6 +115,8 @@ def _main():
|
|||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
|
if args["--oosetup"]:
|
||||||
|
return onlyoffice.setup_docker(_resolve_confdir(args))
|
||||||
listen = args["--listen"]
|
listen = args["--listen"]
|
||||||
# Validate arguments first
|
# Validate arguments first
|
||||||
if args["<path>"]:
|
if args["<path>"]:
|
||||||
@@ -157,9 +155,6 @@ def _main():
|
|||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
# Check for Paskia SSO
|
|
||||||
from cista.sso import PASKIA_BACKEND_URL
|
|
||||||
|
|
||||||
# Print startup box
|
# Print startup box
|
||||||
startup_box = create_startup_box(
|
startup_box = create_startup_box(
|
||||||
folder=config.config.path,
|
folder=config.config.path,
|
||||||
@@ -175,17 +170,22 @@ def _main():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _confdir(args):
|
def _resolve_confdir(args):
|
||||||
|
confdir = None
|
||||||
if args["-c"]:
|
if args["-c"]:
|
||||||
# Custom config directory
|
# Custom config directory
|
||||||
confdir = Path(args["-c"]).resolve()
|
confdir = Path(args["-c"]).resolve()
|
||||||
if confdir.exists() and not confdir.is_dir():
|
if confdir.exists() and not confdir.is_dir():
|
||||||
if confdir.name != config.conffile.name:
|
if confdir.name != "db.toml":
|
||||||
raise ValueError("Config path is not a directory")
|
raise ValueError("Config path is not a directory")
|
||||||
# Accidentally pointed to the db.toml, use parent
|
# Accidentally pointed to the db.toml, use parent
|
||||||
confdir = confdir.parent
|
confdir = confdir.parent
|
||||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
return confdir
|
||||||
config.init_confdir() # Uses environ if available
|
|
||||||
|
|
||||||
|
def _confdir(args):
|
||||||
|
confdir = _resolve_confdir(args)
|
||||||
|
config.init_confdir(confdir)
|
||||||
|
|
||||||
|
|
||||||
def _user(args):
|
def _user(args):
|
||||||
|
|||||||
+54
-87
@@ -1,16 +1,20 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint, json
|
from sanic import Blueprint, json
|
||||||
from sanic.exceptions import BadRequest
|
from sanic.exceptions import BadRequest
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import __version__, auth, config, sso, watching
|
from cista import __version__, auth, config, onlyoffice, sharefs, sso, watching
|
||||||
|
from cista.auth import (
|
||||||
|
create_share_token_handler,
|
||||||
|
create_token_handler,
|
||||||
|
delete_token_handler,
|
||||||
|
list_tokens_handler,
|
||||||
|
)
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.util.apphelpers import websocket_wrapper
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
|
||||||
|
|
||||||
bp = Blueprint("api", url_prefix="/api")
|
bp = Blueprint("api", url_prefix="/api")
|
||||||
fileserver = FileServer()
|
fileserver = FileServer()
|
||||||
@@ -18,84 +22,16 @@ fileserver = FileServer()
|
|||||||
|
|
||||||
@bp.before_server_start
|
@bp.before_server_start
|
||||||
async def start_fileserver(app):
|
async def start_fileserver(app):
|
||||||
|
_ = app
|
||||||
await fileserver.start()
|
await fileserver.start()
|
||||||
|
|
||||||
|
|
||||||
@bp.after_server_stop
|
@bp.after_server_stop
|
||||||
async def stop_fileserver(app):
|
async def stop_fileserver(app):
|
||||||
|
_ = app
|
||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("upload")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def upload(req, ws):
|
|
||||||
alink = fileserver.alink
|
|
||||||
while True:
|
|
||||||
req = None
|
|
||||||
text = await ws.recv()
|
|
||||||
if not isinstance(text, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
|
||||||
)
|
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
|
||||||
pos = req.start
|
|
||||||
while True:
|
|
||||||
data = await ws.recv()
|
|
||||||
if not isinstance(data, bytes):
|
|
||||||
break
|
|
||||||
if len(data) > req.end - pos:
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
|
|
||||||
)
|
|
||||||
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
|
||||||
pos += typing.cast(int, sentsize)
|
|
||||||
if pos >= req.end:
|
|
||||||
break
|
|
||||||
if pos != req.end:
|
|
||||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
|
||||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
|
||||||
# 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")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
@@ -105,8 +41,8 @@ async def watch(req, ws):
|
|||||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
try:
|
try:
|
||||||
await sso.validate_sso_request(req)
|
await sso.validate_sso_request(req)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Ignore auth errors, user_info stays None
|
logger.debug("watch SSO validation failed: %s", e)
|
||||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
ctx = sso_user.get("ctx", {})
|
ctx = sso_user.get("ctx", {})
|
||||||
perms = ctx.get("permissions", [])
|
perms = ctx.get("permissions", [])
|
||||||
@@ -129,21 +65,34 @@ async def watch(req, ws):
|
|||||||
"version": __version__,
|
"version": __version__,
|
||||||
"public": config.config.public,
|
"public": config.config.public,
|
||||||
"paskia": sso.paskia_enabled(),
|
"paskia": sso.paskia_enabled(),
|
||||||
|
"office_previews": await onlyoffice.is_available_cached(),
|
||||||
},
|
},
|
||||||
"user": user_info,
|
"user": user_info,
|
||||||
}
|
}
|
||||||
).decode()
|
).decode()
|
||||||
)
|
)
|
||||||
uuid = token_bytes(16)
|
uuid = token_bytes(16)
|
||||||
|
share_token = auth.request_share_token(req)
|
||||||
try:
|
try:
|
||||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||||
)
|
)
|
||||||
await ws.send(space)
|
await ws.send(space)
|
||||||
await ws.send(root)
|
if share_token is None:
|
||||||
|
await ws.send(root)
|
||||||
|
else:
|
||||||
|
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
|
||||||
# Send updates
|
# Send updates
|
||||||
while True:
|
while True:
|
||||||
await ws.send(await q.get())
|
msg = await q.get()
|
||||||
|
if share_token is None or (
|
||||||
|
isinstance(msg, str) and msg.startswith('{"space"')
|
||||||
|
):
|
||||||
|
await ws.send(msg)
|
||||||
|
else:
|
||||||
|
await ws.send(
|
||||||
|
watching.format_root(sharefs.build_virtual_root(share_token))
|
||||||
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
if str(e) == "cannot schedule new futures after shutdown":
|
if str(e) == "cannot schedule new futures after shutdown":
|
||||||
return # Server shutting down, drop the WebSocket
|
return # Server shutting down, drop the WebSocket
|
||||||
@@ -153,6 +102,7 @@ async def watch(req, ws):
|
|||||||
|
|
||||||
|
|
||||||
def subscribe(uuid, ws):
|
def subscribe(uuid, ws):
|
||||||
|
_ = ws
|
||||||
with watching.state.lock:
|
with watching.state.lock:
|
||||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||||
# Init with disk usage and full tree
|
# Init with disk usage and full tree
|
||||||
@@ -179,12 +129,10 @@ async def update_public(request):
|
|||||||
await auth.verify(request, privileged=True)
|
await auth.verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
public = request.json["public"]
|
public = request.json["public"]
|
||||||
if not isinstance(public, bool):
|
|
||||||
raise ValueError("public must be a boolean")
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise BadRequest("Missing public field") from None
|
raise BadRequest("Missing public field") from None
|
||||||
except ValueError as e:
|
if not isinstance(public, bool):
|
||||||
raise BadRequest(str(e)) from None
|
raise BadRequest("public must be a boolean")
|
||||||
config.update_config({"public": public})
|
config.update_config({"public": public})
|
||||||
return json({"message": "Public access setting updated", "public": public})
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|
||||||
@@ -194,13 +142,32 @@ async def update_name(request):
|
|||||||
await auth.verify(request, privileged=True)
|
await auth.verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
name = request.json["name"]
|
name = request.json["name"]
|
||||||
if not isinstance(name, str):
|
|
||||||
raise ValueError("name must be a string")
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise BadRequest("Missing name field") from None
|
raise BadRequest("Missing name field") from None
|
||||||
except ValueError as e:
|
if not isinstance(name, str):
|
||||||
raise BadRequest(str(e)) from None
|
raise BadRequest("name must be a string")
|
||||||
config.update_config({"name": name})
|
config.update_config({"name": name})
|
||||||
# Return the effective name (fallback to path.name if empty)
|
# Return the effective name (fallback to path.name if empty)
|
||||||
effective_name = name or config.config.path.name
|
effective_name = name or config.config.path.name
|
||||||
return json({"message": "Server name updated", "name": effective_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)
|
||||||
|
|||||||
+127
-84
@@ -3,80 +3,62 @@ import datetime
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from multiprocessing import cpu_count
|
|
||||||
from pathlib import Path, PurePath, PurePosixPath
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
from stat import S_IFDIR, S_IFREG
|
from stat import S_IFDIR, S_IFREG
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import sanic.helpers
|
import tracerite
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
from sanic import Sanic, empty, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, sso, watching
|
from cista import (
|
||||||
|
auth,
|
||||||
|
config,
|
||||||
|
fileserver,
|
||||||
|
onlyoffice,
|
||||||
|
preview,
|
||||||
|
session,
|
||||||
|
sharefs,
|
||||||
|
sso,
|
||||||
|
watching,
|
||||||
|
)
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
from cista.sanic_logging import configure_access_logging, format_access_log
|
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.sanic_logging import logger as access_logger
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
# Workaround until Sanic PR #2824 is merged
|
tracerite.load()
|
||||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
|
||||||
|
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
|
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
app.router.ALLOWED_METHODS = (
|
||||||
if sso.paskia_enabled():
|
*app.router.ALLOWED_METHODS,
|
||||||
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
"MKCOL",
|
||||||
else:
|
"MOVE",
|
||||||
app.blueprint(auth.bp) # Built-in auth routes
|
"COPY",
|
||||||
app.blueprint(preview.bp)
|
"PROPFIND",
|
||||||
app.blueprint(bp)
|
)
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
|
||||||
|
|
||||||
|
configure_main_logging()
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_request
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
req.ctx._log_start = time.perf_counter()
|
req.ctx.log_start = time.perf_counter()
|
||||||
req.ctx.session = session.get(req)
|
req.ctx.auth_flow = ["session: start"]
|
||||||
try:
|
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||||
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
|
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
return # Ordinary GET requests are fine
|
return # Ordinary GET requests are fine
|
||||||
@@ -92,9 +74,9 @@ async def log_access(req, res):
|
|||||||
"""Log HTTP access in a clean single-line format."""
|
"""Log HTTP access in a clean single-line format."""
|
||||||
if req.headers.get("upgrade", "").lower() == "websocket":
|
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||||
return res
|
return res
|
||||||
start = getattr(req.ctx, "_log_start", None)
|
start = getattr(req.ctx, "log_start", None)
|
||||||
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||||
client = req.ip or "-"
|
client = req.client_ip or "-"
|
||||||
host = req.host or "-"
|
host = req.host or "-"
|
||||||
path = req.path
|
path = req.path
|
||||||
if req.query_string:
|
if req.query_string:
|
||||||
@@ -102,7 +84,10 @@ async def log_access(req, res):
|
|||||||
if isinstance(qs, bytes):
|
if isinstance(qs, bytes):
|
||||||
qs = qs.decode(errors="replace")
|
qs = qs.decode(errors="replace")
|
||||||
path = f"{path}?{qs}"
|
path = f"{path}?{qs}"
|
||||||
line = format_access_log(client, res.status, req.method, host, path, duration_ms)
|
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)
|
access_logger.info(line)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@@ -115,23 +100,61 @@ async def forward_sso_cookies(req, res):
|
|||||||
res.headers.add("set-cookie", cookie)
|
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
|
@app.before_server_start
|
||||||
def http_fileserver(app):
|
async def main_start(app):
|
||||||
bp = Blueprint("fileserver")
|
config.load_config()
|
||||||
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
@bp.on_request
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
async def verify_fileserver(request):
|
max_workers=4, thread_name_prefix="cista-worker"
|
||||||
"""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,
|
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_server_start
|
||||||
|
async def main_after_start(app):
|
||||||
|
_ = app
|
||||||
|
onlyoffice.log_reachable_info()
|
||||||
|
|
||||||
|
|
||||||
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
|
@app.before_server_stop
|
||||||
|
async def main_stop(app):
|
||||||
|
watching.stop(app)
|
||||||
|
await onlyoffice.close_oo_client()
|
||||||
|
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 = {}
|
www = {}
|
||||||
@@ -233,29 +256,48 @@ async def wwwroot(req, path=""):
|
|||||||
|
|
||||||
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||||
async def favicon(req):
|
async def favicon(req):
|
||||||
|
_ = req
|
||||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||||
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
||||||
|
|
||||||
|
|
||||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
def get_files(req, wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||||
loc = PurePosixPath()
|
loc = PurePosixPath()
|
||||||
idx = 0
|
idx = 0
|
||||||
ret = []
|
ret = []
|
||||||
level: int | None = None
|
level: int | None = None
|
||||||
parent: PurePosixPath | None = None
|
parent: PurePosixPath | None = None
|
||||||
with watching.state.lock:
|
token = auth.request_share_token(req)
|
||||||
root = watching.state.root
|
|
||||||
while idx < len(root):
|
if token is None:
|
||||||
f = root[idx]
|
with watching.state.lock:
|
||||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
root = watching.state.root
|
||||||
if parent is not None and f.level <= level:
|
while idx < len(root):
|
||||||
level = parent = None
|
f = root[idx]
|
||||||
if f.key in wanted:
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
level, parent = f.level, loc.parent
|
if parent is not None and f.level <= level:
|
||||||
if parent is not None:
|
level = parent = None
|
||||||
wanted.discard(f.key)
|
if f.key in wanted:
|
||||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
level, parent = f.level, loc.parent
|
||||||
idx += 1
|
if parent is not None:
|
||||||
|
wanted.discard(f.key)
|
||||||
|
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||||
|
idx += 1
|
||||||
|
return ret
|
||||||
|
|
||||||
|
root = sharefs.build_virtual_root(token)
|
||||||
|
while idx < len(root):
|
||||||
|
f = root[idx]
|
||||||
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
|
if parent is not None and f.level <= level:
|
||||||
|
level = parent = None
|
||||||
|
if f.key in wanted:
|
||||||
|
level, parent = f.level, loc.parent
|
||||||
|
if parent is not None:
|
||||||
|
wanted.discard(f.key)
|
||||||
|
real_path = sharefs.resolve_virtual_rel_to_real(token, loc)
|
||||||
|
ret.append((loc.relative_to(parent), real_path))
|
||||||
|
idx += 1
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -265,7 +307,7 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
await auth.verify(req)
|
await auth.verify(req)
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(req, wanted)
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
raise NotFound(
|
raise NotFound(
|
||||||
@@ -291,7 +333,8 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
||||||
size -= len(chunk)
|
size -= len(chunk)
|
||||||
yield chunk
|
yield chunk
|
||||||
assert size == 0
|
if size != 0:
|
||||||
|
raise OSError(f"stream ended early while zipping {name}")
|
||||||
|
|
||||||
pending_put = None # Current queue.put future, can be cancelled
|
pending_put = None # Current queue.put future, can be cancelled
|
||||||
|
|
||||||
|
|||||||
+1086
-78
File diff suppressed because it is too large
Load Diff
+48
-9
@@ -3,16 +3,19 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from time import sleep, time
|
from time import sleep, time
|
||||||
from typing import Callable, Concatenate, Literal, ParamSpec
|
from typing import Concatenate, Literal, ParamSpec
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import msgspec.toml
|
import msgspec.toml
|
||||||
|
|
||||||
|
from .util import pwhash
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct):
|
||||||
path: Path
|
path: Path
|
||||||
@@ -22,6 +25,7 @@ class Config(msgspec.Struct):
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
users: dict[str, User] = {}
|
users: dict[str, User] = {}
|
||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
tokens: dict[str, Token] = {}
|
||||||
|
|
||||||
|
|
||||||
# Typing: arguments for config-modifying functions
|
# Typing: arguments for config-modifying functions
|
||||||
@@ -43,15 +47,26 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
expires: int = 0
|
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
|
# Global variables - initialized during application startup
|
||||||
config: Config
|
config: Config
|
||||||
conffile: Path
|
conffile: Path
|
||||||
|
|
||||||
|
|
||||||
def init_confdir() -> None:
|
def init_confdir(confdir: Path | str | None = None) -> None:
|
||||||
global conffile
|
global conffile
|
||||||
if p := os.environ.get("CISTA_HOME"):
|
if confdir is not None:
|
||||||
home = Path(p)
|
home = Path(confdir).expanduser()
|
||||||
else:
|
else:
|
||||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
home = (
|
home = (
|
||||||
@@ -63,7 +78,7 @@ def init_confdir() -> None:
|
|||||||
conffile = home / "db.toml"
|
conffile = home / "db.toml"
|
||||||
|
|
||||||
|
|
||||||
def derived_secret(*params, len=8) -> bytes:
|
def derived_secret(*params, size=8) -> bytes:
|
||||||
"""Used to derive secret keys from the main secret"""
|
"""Used to derive secret keys from the main secret"""
|
||||||
# Each part is made the same length by hashing first
|
# Each part is made the same length by hashing first
|
||||||
combined = b"".join(
|
combined = b"".join(
|
||||||
@@ -71,7 +86,7 @@ def derived_secret(*params, len=8) -> bytes:
|
|||||||
for p in [config.secret, *params]
|
for p in [config.secret, *params]
|
||||||
)
|
)
|
||||||
# Output a bytes of the desired length
|
# Output a bytes of the desired length
|
||||||
return sha256(combined).digest()[:len]
|
return sha256(combined).digest()[:size]
|
||||||
|
|
||||||
|
|
||||||
def enc_hook(obj):
|
def enc_hook(obj):
|
||||||
@@ -186,9 +201,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
u = User()
|
u = User()
|
||||||
if "password" in changes:
|
if "password" in changes:
|
||||||
from . import auth
|
pwhash.set_password(u, changes["password"])
|
||||||
|
|
||||||
auth.set_password(u, changes["password"])
|
|
||||||
del changes["password"]
|
del changes["password"]
|
||||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||||
udict.update(changes)
|
udict.update(changes)
|
||||||
@@ -204,3 +217,29 @@ def del_user(conf: Config, name: str) -> Config:
|
|||||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings["users"].pop(name)
|
settings["users"].pop(name)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
|
@modifies_config
|
||||||
|
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
|
||||||
|
"""Create or update a token."""
|
||||||
|
try:
|
||||||
|
t = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
|
||||||
|
Token,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
t = Token()
|
||||||
|
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
|
||||||
|
tdict.update(changes)
|
||||||
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
|
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
|
@modifies_config
|
||||||
|
def del_token(conf: Config, token_id: str) -> Config:
|
||||||
|
"""Delete a token by its stable id."""
|
||||||
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
|
settings["tokens"].pop(token_id, None)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Patched OnlyOffice Document Server with configurable converter worker count.
|
||||||
|
#
|
||||||
|
# The Community Edition hardcodes the document converter to 1 worker,
|
||||||
|
# which creates a severe bottleneck under concurrent load.
|
||||||
|
# This image patches the open-source license.js to spawn a configurable
|
||||||
|
# number of converter workers (default 8).
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# docker run -d -p 8988:80 \
|
||||||
|
# -e WORKERS=16 \
|
||||||
|
# -e JWT_SECRET=your-strong-secret \
|
||||||
|
# --name onlyoffice onlyoffice-cista
|
||||||
|
#
|
||||||
|
# JWT:
|
||||||
|
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# OnlyOffice will enable token validation automatically.
|
||||||
|
#
|
||||||
|
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
|
||||||
|
|
||||||
|
ARG ONLYOFFICE_VERSION=9.3.1
|
||||||
|
|
||||||
|
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
|
||||||
|
|
||||||
|
# Prevent interactive apt prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install Node.js, npm, and git so we can run the FileConverter from source.
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Clone the open-source server components (shallow, ~15 MB).
|
||||||
|
# The master branch is used because the Linux/web tags are not published
|
||||||
|
# in the server repo; the license.js file has been stable for years.
|
||||||
|
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
||||||
|
|
||||||
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
|
# instead of being hardcoded to 1.
|
||||||
|
RUN sed -i \
|
||||||
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Back up the compiled pkg binary and replace it with our wrapper.
|
||||||
|
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
|
||||||
|
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
|
||||||
|
|
||||||
|
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
|
||||||
|
# Default worker count (override at runtime with -e WORKERS=16).
|
||||||
|
ENV WORKERS=8
|
||||||
|
|
||||||
|
# Use our custom entrypoint to persist the env var to a file that the
|
||||||
|
# non-root converter process (user=ds) can read.
|
||||||
|
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
|
||||||
|
RUN chmod +x /app/ds/run-document-server-patched.sh
|
||||||
|
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
|
||||||
|
# Replaces the compiled pkg binary shipped with the Community Edition.
|
||||||
|
|
||||||
|
# The env var is not passed through supervisor to the 'ds' user, so we read
|
||||||
|
# it from a file written by the custom entrypoint.
|
||||||
|
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
|
||||||
|
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /opt/oo-server/FileConverter || exit 1
|
||||||
|
|
||||||
|
export NODE_ENV=production-linux
|
||||||
|
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
|
||||||
|
export NODE_DISABLE_COLORS=1
|
||||||
|
export APPLICATION_NAME=onlyoffice
|
||||||
|
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
|
||||||
|
|
||||||
|
exec node sources/convertermaster.js "$@"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Custom entrypoint that persists WORKERS to a file readable by
|
||||||
|
# the non-root user that supervisor uses to run the converter.
|
||||||
|
|
||||||
|
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
|
||||||
|
chmod 644 /tmp/oo-converter-workers.txt
|
||||||
|
|
||||||
|
exec /app/ds/run-document-server.sh "$@"
|
||||||
+1
-1
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
|
|||||||
for listener in cf["listeners"]:
|
for listener in cf["listeners"]:
|
||||||
try:
|
try:
|
||||||
if listener["protocol"] == "https":
|
if listener["protocol"] == "https":
|
||||||
# TODO: Add support for TLS
|
# TLS listeners are currently ignored here.
|
||||||
continue
|
continue
|
||||||
socket = listener.get("socket")
|
socket = listener.get("socket")
|
||||||
if socket:
|
if socket:
|
||||||
|
|||||||
+33
-36
@@ -1,9 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
from cista.util.asynclink import AsyncLink
|
|
||||||
from cista.util.lrucache import LRUCache
|
from cista.util.lrucache import LRUCache
|
||||||
|
|
||||||
|
|
||||||
@@ -32,20 +32,23 @@ class File:
|
|||||||
if not self.writable:
|
if not self.writable:
|
||||||
# Create/open file
|
# Create/open file
|
||||||
self.open_rw()
|
self.open_rw()
|
||||||
assert self.fd is not None
|
if self.fd is None:
|
||||||
|
raise RuntimeError("file descriptor is not available for write")
|
||||||
if file_size is not None:
|
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)
|
os.ftruncate(self.fd, file_size)
|
||||||
if buffer:
|
if buffer:
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
os.write(self.fd, buffer)
|
os.write(self.fd, buffer)
|
||||||
|
|
||||||
def __getitem__(self, slice):
|
def __getitem__(self, slc):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
self.open_ro()
|
self.open_ro()
|
||||||
assert self.fd is not None
|
if self.fd is None:
|
||||||
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
raise RuntimeError("file descriptor is not available for read")
|
||||||
size = slice.stop - slice.start
|
os.lseek(self.fd, slc.start, os.SEEK_SET)
|
||||||
|
size = slc.stop - slc.start
|
||||||
data = os.read(self.fd, size)
|
data = os.read(self.fd, size)
|
||||||
if len(data) < size:
|
if len(data) < size:
|
||||||
raise EOFError("Error reading requested range")
|
raise EOFError("Error reading requested range")
|
||||||
@@ -62,38 +65,32 @@ class File:
|
|||||||
|
|
||||||
class FileServer:
|
class FileServer:
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.alink = AsyncLink()
|
|
||||||
self.worker = asyncio.get_event_loop().run_in_executor(
|
|
||||||
None,
|
|
||||||
self.worker_thread,
|
|
||||||
self.alink.to_sync,
|
|
||||||
)
|
|
||||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||||
|
self.cache_lock = threading.Lock()
|
||||||
|
self.file_locks: dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
await self.alink.stop()
|
self.cache.close()
|
||||||
await self.worker
|
|
||||||
|
|
||||||
def worker_thread(self, slink):
|
@staticmethod
|
||||||
|
def _stat_size(path):
|
||||||
try:
|
try:
|
||||||
for req in slink:
|
return Path(path).stat().st_size
|
||||||
with req as (command, *args):
|
except FileNotFoundError:
|
||||||
if command == "upload":
|
return None
|
||||||
req.set_result(self.upload(*args))
|
|
||||||
elif command == "download":
|
|
||||||
req.set_result(self.download(*args))
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"Unhandled {command=} {args}")
|
|
||||||
finally:
|
|
||||||
self.cache.close()
|
|
||||||
|
|
||||||
def upload(self, name, pos, data, file_size):
|
def upload_info(self, name, pos, data, file_size):
|
||||||
name = filename.sanitize(name)
|
name = filename.sanitize(name)
|
||||||
f = self.cache[name]
|
with self.cache_lock:
|
||||||
f.write(pos, data, file_size=file_size)
|
f = self.cache[name]
|
||||||
return len(data)
|
lock = self.file_locks.setdefault(name, threading.Lock())
|
||||||
|
with lock:
|
||||||
def download(self, name, start, end):
|
size_before = self._stat_size(f.path)
|
||||||
name = filename.sanitize(name)
|
f.write(pos, data, file_size=file_size)
|
||||||
f = self.cache[name]
|
size_after = self._stat_size(f.path)
|
||||||
return f[start:end]
|
return {
|
||||||
|
"written": len(data),
|
||||||
|
"created": size_before is None,
|
||||||
|
"size_before": size_before,
|
||||||
|
"size_after": size_after,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
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)):
|
||||||
|
for key in op_keys:
|
||||||
|
try:
|
||||||
|
src_rel = key_paths[key]
|
||||||
|
src_abs = _resolve_from_relpath(src_rel, request=request)
|
||||||
|
|
||||||
|
if dst_is_dir:
|
||||||
|
dst_item_rel = (
|
||||||
|
dst_rel / src_rel.name
|
||||||
|
if dst_rel.parts
|
||||||
|
else PurePosixPath(src_rel.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dst_item_rel = dst_rel
|
||||||
|
|
||||||
|
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
|
||||||
|
|
||||||
|
if op_name == "mv":
|
||||||
|
# A no-op rename should still return success.
|
||||||
|
if src_abs != dst_item_abs:
|
||||||
|
shutil.move(src_abs, dst_item_abs)
|
||||||
|
changed.add(src_rel)
|
||||||
|
changed.add(src_rel.parent)
|
||||||
|
elif src_abs.is_dir():
|
||||||
|
shutil.copytree(
|
||||||
|
src_abs,
|
||||||
|
dst_item_abs,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
ignore_dangling_symlinks=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_abs, dst_item_abs)
|
||||||
|
|
||||||
|
changed.add(dst_item_rel)
|
||||||
|
changed.add(dst_item_rel.parent)
|
||||||
|
completed.append({"op": op_name, "key": key})
|
||||||
|
except Exception as e:
|
||||||
|
raise _FileOpError(op_name, key, e) from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_apply)
|
||||||
|
except _FileOpError as e:
|
||||||
|
raise BadRequest(
|
||||||
|
"File operation failed after partial progress",
|
||||||
|
context={
|
||||||
|
"failed_op": e.op_name,
|
||||||
|
"failed_key": e.key,
|
||||||
|
"error": str(e.error),
|
||||||
|
"completed": completed,
|
||||||
|
},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
notify_paths = [p for p in changed if p.parts]
|
||||||
|
if notify_paths:
|
||||||
|
real_notify_paths: list[PurePosixPath] = []
|
||||||
|
for p in notify_paths:
|
||||||
|
real_abs = _resolve_from_relpath(p, request=request)
|
||||||
|
real_notify_paths.append(
|
||||||
|
PurePosixPath(
|
||||||
|
real_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
watching.notify_change(*real_notify_paths)
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"status": "ack",
|
||||||
|
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<name:path>")
|
||||||
|
async def get_file(request, name=""):
|
||||||
|
return await _send_static_file(request, name, head_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.head("/<name:path>")
|
||||||
|
async def head_file(request, name=""):
|
||||||
|
return await _send_static_file(request, name, head_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
||||||
|
async def dav_options(request, name=""):
|
||||||
|
_ = request
|
||||||
|
_ = name
|
||||||
|
return HTTPResponse(
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
|
||||||
|
"DAV": "1",
|
||||||
|
"MS-Author-Via": "DAV",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
|
||||||
|
async def dav_propfind(request, name=""):
|
||||||
|
rel, path = _safe_relpath(name, request=request)
|
||||||
|
token = auth.request_share_token(request)
|
||||||
|
if token is not None and not rel.parts:
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
entries = [_propfind_entry(PurePosixPath(), base)]
|
||||||
|
depth = request.headers.get("depth", "1").strip()
|
||||||
|
if depth == "infinity":
|
||||||
|
return HTTPResponse(status=403)
|
||||||
|
if depth == "1":
|
||||||
|
for root in sharefs.build_share_roots(token):
|
||||||
|
child_abs = (base / root.real_rel).resolve()
|
||||||
|
if not child_abs.exists() or not child_abs.is_relative_to(base):
|
||||||
|
continue
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
entries.append(
|
||||||
|
_propfind_entry(PurePosixPath(root.alias), child_abs)
|
||||||
|
)
|
||||||
|
return HTTPResponse(
|
||||||
|
body=_build_propfind_xml(entries),
|
||||||
|
status=207,
|
||||||
|
content_type='application/xml; charset="utf-8"',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
raise NotFound(f"Not found: {name}")
|
||||||
|
depth = request.headers.get("depth", "1").strip()
|
||||||
|
if depth == "infinity":
|
||||||
|
return HTTPResponse(status=403)
|
||||||
|
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
|
||||||
|
return HTTPResponse(
|
||||||
|
body=_build_propfind_xml(entries),
|
||||||
|
status=207,
|
||||||
|
content_type='application/xml; charset="utf-8"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
|
||||||
|
async def dav_copy(request, name=""):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
|
||||||
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
raise BadRequest("Source and destination are the same")
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_copy():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
if src_abs.is_dir():
|
||||||
|
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_copy)
|
||||||
|
real_dst_rel = PurePosixPath(
|
||||||
|
dst_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
watching.notify_change(real_dst_rel, *real_dst_rel.parents)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
|
||||||
|
async def dav_move(request, name=""):
|
||||||
|
auth.ensure_write_allowed(request)
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
|
||||||
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
return HTTPResponse(status=204)
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_move():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
shutil.move(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_move)
|
||||||
|
real_src_rel = PurePosixPath(
|
||||||
|
src_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
real_dst_rel = PurePosixPath(
|
||||||
|
dst_abs.relative_to(config.config.path.resolve()).as_posix()
|
||||||
|
)
|
||||||
|
watching.notify_change(
|
||||||
|
real_src_rel, *real_src_rel.parents, real_dst_rel, *real_dst_rel.parents
|
||||||
|
)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||||
|
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||||
|
if m is None:
|
||||||
|
raise BadRequest("Invalid Content-Range format")
|
||||||
|
start, end_inclusive, total = (int(v) for v in m.groups())
|
||||||
|
if total <= 0:
|
||||||
|
raise BadRequest("Invalid Content-Range total size")
|
||||||
|
if start > end_inclusive:
|
||||||
|
raise BadRequest("Invalid Content-Range range")
|
||||||
|
if end_inclusive >= total:
|
||||||
|
raise BadRequest("Content-Range exceeds total size")
|
||||||
|
expected_len = end_inclusive - start + 1
|
||||||
|
if expected_len != body_len:
|
||||||
|
raise BadRequest(
|
||||||
|
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
|
||||||
|
)
|
||||||
|
return start, end_inclusive + 1, total
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mib_int(value_bytes: int) -> int:
|
||||||
|
return round(value_bytes / (1 << 20))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relpath(path: str, *, request=None) -> tuple[PurePosixPath, Path]:
|
||||||
|
"""Resolve a user path under storage root and enforce containment."""
|
||||||
|
token = auth.request_share_token(request) if request is not None else None
|
||||||
|
if token is not None:
|
||||||
|
vrel, _rrel, resolved, is_root = sharefs.resolve_virtual_path(token, path)
|
||||||
|
if is_root:
|
||||||
|
return vrel, config.config.path.resolve()
|
||||||
|
return vrel, resolved
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
try:
|
||||||
|
sanitized = filename.sanitize(unquote(path))
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(f"Invalid path: {e}") from e
|
||||||
|
resolved = (base / sanitized).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
rel = PurePosixPath(resolved.relative_to(base).as_posix())
|
||||||
|
return rel, resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_from_relpath(rel: PurePosixPath, *, request=None) -> Path:
|
||||||
|
"""Resolve a relative path under storage root and enforce containment."""
|
||||||
|
token = auth.request_share_token(request) if request is not None else None
|
||||||
|
if token is not None:
|
||||||
|
return sharefs.resolve_virtual_rel_to_real(token, rel)
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
resolved = (base / rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_static_file(request, name: str, *, head_only: bool):
|
||||||
|
_, path = _safe_relpath(name, request=request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = await asyncio.to_thread(path.stat)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotFound(f"File not found: {name}") from None
|
||||||
|
if path.is_dir():
|
||||||
|
raise NotFound(f"Not a file: {name}")
|
||||||
|
|
||||||
|
size = st.st_size
|
||||||
|
start = 0
|
||||||
|
end_excl = size
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
if range_header is not None:
|
||||||
|
parsed = _parse_range_header(range_header, size)
|
||||||
|
if parsed is None:
|
||||||
|
return empty(
|
||||||
|
status=416,
|
||||||
|
headers={
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"content-range": f"bytes */{size}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
start, end_excl = parsed
|
||||||
|
status = 206
|
||||||
|
|
||||||
|
length = end_excl - start
|
||||||
|
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
headers = {
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-length": str(length),
|
||||||
|
"content-type": mime,
|
||||||
|
"last-modified": format_date_time(st.st_mtime),
|
||||||
|
}
|
||||||
|
if status == 206:
|
||||||
|
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
|
||||||
|
|
||||||
|
if head_only:
|
||||||
|
return empty(status=status, headers=headers)
|
||||||
|
|
||||||
|
res = await request.respond(status=status, headers=headers)
|
||||||
|
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
pos = start
|
||||||
|
while pos < end_excl:
|
||||||
|
chunk = await asyncio.to_thread(
|
||||||
|
os.pread,
|
||||||
|
fd,
|
||||||
|
min(_FILE_CHUNK_SIZE, end_excl - pos),
|
||||||
|
pos,
|
||||||
|
)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
pos += len(chunk)
|
||||||
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(os.close, fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
|
||||||
|
value = header.strip()
|
||||||
|
if "," in value:
|
||||||
|
return None
|
||||||
|
m = _RANGE_RE.fullmatch(value)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_s, end_s = m.groups()
|
||||||
|
if not start_s and not end_s:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if start_s:
|
||||||
|
start = int(start_s)
|
||||||
|
if start >= size:
|
||||||
|
return None
|
||||||
|
end_inclusive = int(end_s) if end_s else (size - 1)
|
||||||
|
if end_inclusive < start:
|
||||||
|
return None
|
||||||
|
end_inclusive = min(end_inclusive, size - 1)
|
||||||
|
return start, end_inclusive + 1
|
||||||
|
|
||||||
|
suffix_len = int(end_s)
|
||||||
|
if suffix_len <= 0:
|
||||||
|
return None
|
||||||
|
if suffix_len >= size:
|
||||||
|
return 0, size
|
||||||
|
start = size - suffix_len
|
||||||
|
return start, size
|
||||||
|
|
||||||
|
|
||||||
|
def _get_key_paths(request, wanted: set[str]) -> dict[str, PurePosixPath]:
|
||||||
|
"""Map file keys to their current relative filesystem paths."""
|
||||||
|
token = auth.request_share_token(request)
|
||||||
|
if token is not None:
|
||||||
|
return sharefs.key_paths_for_token(token, wanted)
|
||||||
|
|
||||||
|
loc = PurePosixPath()
|
||||||
|
ret: dict[str, PurePosixPath] = {}
|
||||||
|
with watching.state.lock:
|
||||||
|
root = watching.state.root
|
||||||
|
for f in root:
|
||||||
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
|
if f.key in wanted and f.key not in ret:
|
||||||
|
ret[f.key] = loc
|
||||||
|
if len(ret) == len(wanted):
|
||||||
|
break
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WebDAV helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_webdav_destination(
|
||||||
|
dest_header: str, *, request=None
|
||||||
|
) -> tuple[PurePosixPath, Path]:
|
||||||
|
"""Parse a WebDAV Destination header and resolve it to a storage path."""
|
||||||
|
parsed = urlparse(dest_header)
|
||||||
|
raw_path = parsed.path # still percent-encoded
|
||||||
|
prefix = "/files"
|
||||||
|
if raw_path in (prefix, prefix + "/"):
|
||||||
|
rel_str = ""
|
||||||
|
elif raw_path.startswith(prefix + "/"):
|
||||||
|
rel_str = raw_path[len(prefix) + 1 :]
|
||||||
|
else:
|
||||||
|
raise BadRequest("Destination must be within /files")
|
||||||
|
return _safe_relpath(rel_str, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
|
||||||
|
"""Build a DAV href from a storage-relative path."""
|
||||||
|
parts = rel.parts
|
||||||
|
if not parts:
|
||||||
|
return "/files/"
|
||||||
|
encoded = "/".join(url_quote(p, safe="") for p in parts)
|
||||||
|
href = f"/files/{encoded}"
|
||||||
|
return href + "/" if is_dir else href
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_xml(element: ET.Element) -> bytes:
|
||||||
|
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
|
||||||
|
return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
|
||||||
|
element, encoding="unicode"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
|
||||||
|
entries = [_propfind_entry(rel, path)]
|
||||||
|
if depth == "1" and path.is_dir():
|
||||||
|
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
|
||||||
|
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
entries.append(_propfind_entry(child_rel, child))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
|
||||||
|
st = path.stat()
|
||||||
|
is_dir = path.is_dir()
|
||||||
|
return {
|
||||||
|
"href": _rel_to_href(rel, is_dir=is_dir),
|
||||||
|
"name": rel.parts[-1] if rel.parts else "",
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size": st.st_size,
|
||||||
|
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
|
||||||
|
"content_type": mimetypes.guess_type(path.name)[0]
|
||||||
|
or "application/octet-stream",
|
||||||
|
"last_modified": format_date_time(st.st_mtime),
|
||||||
|
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_propfind_xml(entries: list[dict]) -> bytes:
|
||||||
|
multistatus = ET.Element(_dav_tag("multistatus"))
|
||||||
|
for e in entries:
|
||||||
|
response = ET.SubElement(multistatus, _dav_tag("response"))
|
||||||
|
ET.SubElement(response, _dav_tag("href")).text = e["href"]
|
||||||
|
propstat = ET.SubElement(response, _dav_tag("propstat"))
|
||||||
|
prop = ET.SubElement(propstat, _dav_tag("prop"))
|
||||||
|
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
|
||||||
|
if e["is_dir"]:
|
||||||
|
ET.SubElement(rt, _dav_tag("collection"))
|
||||||
|
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
|
||||||
|
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
|
||||||
|
if not e["is_dir"]:
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
|
||||||
|
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
|
||||||
|
return _dav_xml(multistatus)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""OnlyOffice Document Server integration for office document preview.
|
||||||
|
|
||||||
|
Provides server-side conversion of office documents to PNG via the
|
||||||
|
OnlyOffice Document Server /ConvertService.ashx API. The resulting PNG
|
||||||
|
is passed through pyvips for AVIF compression.
|
||||||
|
|
||||||
|
Environment requirements:
|
||||||
|
- OnlyOffice Document Server must be running and reachable.
|
||||||
|
- If Document Server runs in Docker, the callback host IP must be
|
||||||
|
reachable from the container (usually the docker bridge IP).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_httpx_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onlyoffice_url() -> str:
|
||||||
|
return os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_secret() -> str:
|
||||||
|
return (
|
||||||
|
os.environ.get("ONLYOFFICE_JWT_SECRET")
|
||||||
|
or config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_callback_host() -> str:
|
||||||
|
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
|
||||||
|
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
||||||
|
return host
|
||||||
|
# Try to auto-detect docker bridge IP
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "inet " in line:
|
||||||
|
parts = line.strip().split()
|
||||||
|
addr_part = parts[1] # e.g. 172.17.0.1/16
|
||||||
|
return addr_part.split("/")[0]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to auto-detect docker bridge IP")
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async HTTP client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_httpx_client() -> httpx.AsyncClient:
|
||||||
|
"""Return the shared async HTTP client for OnlyOffice requests."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is None:
|
||||||
|
_httpx_client = httpx.AsyncClient()
|
||||||
|
return _httpx_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_oo_client() -> None:
|
||||||
|
"""Close the shared async HTTP client."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is not None:
|
||||||
|
await _httpx_client.aclose()
|
||||||
|
_httpx_client = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Availability check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_status() -> tuple[bool, bool, str | None]:
|
||||||
|
"""Return (ok, responded, detail) for a lightweight reachability probe."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as resp: # noqa: S310
|
||||||
|
status = resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status = e.code
|
||||||
|
except Exception:
|
||||||
|
return False, False, None
|
||||||
|
|
||||||
|
if status in (200, 405):
|
||||||
|
return True, True, None
|
||||||
|
if status >= 500:
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_reachable_info() -> None:
|
||||||
|
"""Log info on success, warning on responded probe errors, silent on no-response."""
|
||||||
|
ok, responded, detail = _probe_status()
|
||||||
|
if ok:
|
||||||
|
logger.info("Using OnlyOffice document server at %s", _get_onlyoffice_url())
|
||||||
|
elif responded:
|
||||||
|
suffix = f": {detail}" if detail else ""
|
||||||
|
logger.warning("OnlyOffice probe failed%s", suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_docker(confdir: Path | None = None) -> int:
|
||||||
|
"""Build and run the patched OnlyOffice Docker image."""
|
||||||
|
config.init_confdir(confdir)
|
||||||
|
if config.conffile.exists():
|
||||||
|
config.load_config()
|
||||||
|
else:
|
||||||
|
config.update_config(
|
||||||
|
{
|
||||||
|
"listen": ":8989",
|
||||||
|
"path": Path.home() / "Downloads",
|
||||||
|
"public": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
docker_dir = Path(__file__).parent / "docker"
|
||||||
|
if not docker_dir.is_dir():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Docker files not found at {docker_dir}. Is the package installed correctly?"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Building OnlyOffice image")
|
||||||
|
build_cmd = ["docker", "build", "-t", "onlyoffice-cista", str(docker_dir)]
|
||||||
|
logger.info("%s", " ".join(build_cmd))
|
||||||
|
result = subprocess.run(build_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to build OnlyOffice image")
|
||||||
|
|
||||||
|
logger.info("Starting OnlyOffice container")
|
||||||
|
run_cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"-p",
|
||||||
|
"8988:80",
|
||||||
|
"-e",
|
||||||
|
f"JWT_SECRET={secret}",
|
||||||
|
"-e",
|
||||||
|
"WORKERS=8",
|
||||||
|
"--name",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
]
|
||||||
|
logger.info("%s", " ".join(run_cmd))
|
||||||
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to start OnlyOffice container")
|
||||||
|
logger.info("OnlyOffice is running on http://localhost:8988")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_async(request_timeout: float = 2.0) -> bool:
|
||||||
|
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=request_timeout)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return response.status_code in (200, 405)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_available_cache: tuple[bool, float] | None = None
|
||||||
|
OO_AVAILABILITY_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_cached() -> bool:
|
||||||
|
"""Return cached OnlyOffice availability, refreshed every 30 seconds."""
|
||||||
|
global _oo_available_cache
|
||||||
|
now = perf_counter()
|
||||||
|
if _oo_available_cache is not None:
|
||||||
|
result, timestamp = _oo_available_cache
|
||||||
|
if now - timestamp < OO_AVAILABILITY_CACHE_TTL:
|
||||||
|
return result
|
||||||
|
result = await is_available_async()
|
||||||
|
_oo_available_cache = (result, now)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Temporary HTTP server so OnlyOffice can download the file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("0.0.0.0", 0)) # noqa: S104
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_file_temporarily(file_path: Path):
|
||||||
|
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
|
||||||
|
directory = str(file_path.parent)
|
||||||
|
filename = file_path.name
|
||||||
|
port = _get_free_port()
|
||||||
|
|
||||||
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
|
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
host = _get_callback_host()
|
||||||
|
url = f"http://{host}:{port}/{quote(filename)}"
|
||||||
|
return url, httpd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OnlyOffice conversion client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_jwt_token(payload: dict) -> str | None:
|
||||||
|
secret = _get_jwt_secret()
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
|
||||||
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
|
"""
|
||||||
|
oo_url = _get_onlyoffice_url().rstrip("/")
|
||||||
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
|
||||||
|
# Start temporary HTTP server so OnlyOffice can fetch the file
|
||||||
|
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
||||||
|
try:
|
||||||
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
|
payload = {
|
||||||
|
"async": False,
|
||||||
|
"filetype": suffix,
|
||||||
|
"key": f"cista_{(await asyncio.to_thread(file_path.stat)).st_mtime_ns}",
|
||||||
|
"outputtype": "png",
|
||||||
|
"title": file_path.name,
|
||||||
|
"url": doc_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
token = _build_jwt_token(payload)
|
||||||
|
if token:
|
||||||
|
# Conversion API expects JWT in request body when token checks are enabled.
|
||||||
|
payload["token"] = token
|
||||||
|
headers["Authorization"] = token
|
||||||
|
|
||||||
|
t_start = perf_counter()
|
||||||
|
response = await client.post(
|
||||||
|
convert_url,
|
||||||
|
content=json.dumps(payload).encode(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=request_timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.content
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
# Parse XML response
|
||||||
|
text = body.decode("utf-8", errors="replace")
|
||||||
|
if "<Error>" in text:
|
||||||
|
code = "unknown"
|
||||||
|
if "<Error>" in text and "</Error>" in text:
|
||||||
|
code = text.split("<Error>")[1].split("</Error>")[0]
|
||||||
|
raise RuntimeError(f"OnlyOffice conversion error: {code}")
|
||||||
|
|
||||||
|
if "<FileUrl>" not in text:
|
||||||
|
raise RuntimeError("OnlyOffice response did not contain FileUrl")
|
||||||
|
|
||||||
|
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
||||||
|
file_url = file_url.replace("&", "&")
|
||||||
|
|
||||||
|
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
||||||
|
|
||||||
|
# Download converted PNG
|
||||||
|
png_response = await client.get(file_url, timeout=request_timeout)
|
||||||
|
png_response.raise_for_status()
|
||||||
|
return png_response.content
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
+563
-262
@@ -1,30 +1,34 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import gc
|
import contextlib
|
||||||
import io
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath
|
from multiprocessing import cpu_count
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
import av
|
import httpx
|
||||||
import fitz # PyMuPDF
|
import msgspec
|
||||||
import numpy as np
|
from blake3 import blake3
|
||||||
import pillow_heif
|
|
||||||
from PIL import Image
|
|
||||||
from sanic import Blueprint, empty, raw, redirect
|
from sanic import Blueprint, empty, raw, redirect
|
||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import auth, config
|
from cista import auth, config, onlyoffice, sharefs
|
||||||
|
from cista.preview_worker import (
|
||||||
|
DOC_PREVIEW_SUFFIXES,
|
||||||
|
OFFICE_PREVIEW_SUFFIXES,
|
||||||
|
PreviewRequest,
|
||||||
|
PreviewResponse,
|
||||||
|
)
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
pillow_heif.register_heif_opener()
|
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +74,332 @@ class PreviewCache:
|
|||||||
# Global preview cache instance
|
# Global preview cache instance
|
||||||
_preview_cache = PreviewCache(capacity=500)
|
_preview_cache = PreviewCache(capacity=500)
|
||||||
|
|
||||||
|
PREVIEW_TIMEOUT = 10.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,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
|
if self.proc.returncode is not None:
|
||||||
|
raise WorkerProtocolError("worker already exited")
|
||||||
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
|
meta = msgspec.json.encode(
|
||||||
|
PreviewRequest(
|
||||||
|
path=str(filepath),
|
||||||
|
quality=quality,
|
||||||
|
maxsize=maxsize,
|
||||||
|
maxzoom=maxzoom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = data or b""
|
||||||
|
packet = struct.pack("<II", len(meta), len(payload)) + meta + payload
|
||||||
|
self.proc.stdin.write(packet)
|
||||||
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
|
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||||
|
header = await self.proc.stdout.readexactly(8)
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
if json_size > WORKER_MAX_JSON_BYTES:
|
||||||
|
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
|
||||||
|
meta_raw = await self.proc.stdout.readexactly(json_size)
|
||||||
|
payload = await self.proc.stdout.readexactly(data_size)
|
||||||
|
packet = header + meta_raw + payload
|
||||||
|
if blake3(packet).digest() != checksum:
|
||||||
|
raise WorkerChecksumError("worker checksum mismatch")
|
||||||
|
|
||||||
|
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
||||||
|
if not resp.ok:
|
||||||
|
raise PreviewError(
|
||||||
|
resp.error or "preview worker error",
|
||||||
|
stderr=resp.stderr,
|
||||||
|
backend=resp.backend,
|
||||||
|
)
|
||||||
|
return payload or None, resp
|
||||||
|
|
||||||
|
async def kill(self) -> None:
|
||||||
|
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._pending: asyncio.PriorityQueue[tuple[int, int, asyncio.Future, tuple]] = (
|
||||||
|
asyncio.PriorityQueue()
|
||||||
|
)
|
||||||
|
self._workers: set[_PreviewWorker] = set()
|
||||||
|
self._dispatchers: list[asyncio.Task] = []
|
||||||
|
self._seq = 0
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def _read_startup_stderr(self, proc: asyncio.subprocess.Process) -> str:
|
||||||
|
if proc.stderr is None:
|
||||||
|
return ""
|
||||||
|
with contextlib.suppress(TimeoutError):
|
||||||
|
data = await asyncio.wait_for(proc.stderr.read(), timeout=0.5)
|
||||||
|
return data.decode(errors="replace").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
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.PIPE,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
_active_procs.add(proc)
|
||||||
|
try:
|
||||||
|
ready = await asyncio.wait_for(proc.stdout.readexactly(1), timeout=30.0)
|
||||||
|
except TimeoutError as err:
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
proc.kill()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await proc.wait()
|
||||||
|
stderr = await self._read_startup_stderr(proc)
|
||||||
|
if stderr:
|
||||||
|
raise WorkerProtocolError(
|
||||||
|
"preview worker failed to become ready: " + stderr.splitlines()[-1]
|
||||||
|
) from err
|
||||||
|
raise WorkerProtocolError("preview worker failed to become ready") from err
|
||||||
|
except asyncio.IncompleteReadError as err:
|
||||||
|
stderr = await self._read_startup_stderr(proc)
|
||||||
|
if stderr:
|
||||||
|
raise WorkerProtocolError(
|
||||||
|
"preview worker exited before signalling readiness: "
|
||||||
|
+ stderr.splitlines()[-1]
|
||||||
|
) from err
|
||||||
|
raise WorkerProtocolError(
|
||||||
|
"preview worker exited before signalling readiness"
|
||||||
|
) from err
|
||||||
|
if ready != b"\x01":
|
||||||
|
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
||||||
|
return _PreviewWorker(proc)
|
||||||
|
|
||||||
|
async def _add_worker(self) -> None:
|
||||||
|
worker = await self._spawn_worker()
|
||||||
|
self._workers.add(worker)
|
||||||
|
await self._idle.put(worker)
|
||||||
|
|
||||||
|
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||||
|
self._workers.discard(worker)
|
||||||
|
await worker.kill()
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._add_worker()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to replace preview worker")
|
||||||
|
|
||||||
|
async def _dispatch_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_priority, _seq, future, args = await self._pending.get()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if future.cancelled():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker = await asyncio.wait_for(
|
||||||
|
self._idle.get(), timeout=PREVIEW_TIMEOUT
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker unavailable (%ds) for %s",
|
||||||
|
int(PREVIEW_TIMEOUT),
|
||||||
|
args[0].name,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(PreviewTimeoutError(args[0].name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
filepath = args[0]
|
||||||
|
replace = False
|
||||||
|
try:
|
||||||
|
out, resp = await asyncio.wait_for(
|
||||||
|
worker.request(*args),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result((out, resp))
|
||||||
|
except TimeoutError:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(PreviewTimeoutError(filepath.name))
|
||||||
|
except WorkerChecksumError:
|
||||||
|
replace = True
|
||||||
|
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||||
|
)
|
||||||
|
except PreviewError as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
except (
|
||||||
|
WorkerProtocolError,
|
||||||
|
asyncio.IncompleteReadError,
|
||||||
|
BrokenPipeError,
|
||||||
|
ConnectionResetError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
msgspec.json.DecodeError,
|
||||||
|
) as e:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(
|
||||||
|
f"worker protocol failure for {filepath.name}: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
replace = True
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected preview worker error for %s", filepath.name
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if replace:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
elif worker.proc.returncode is None:
|
||||||
|
await self._idle.put(worker)
|
||||||
|
else:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
workers = await asyncio.gather(
|
||||||
|
*(self._spawn_worker() for _ in range(self.size))
|
||||||
|
)
|
||||||
|
for worker in workers:
|
||||||
|
self._workers.add(worker)
|
||||||
|
await self._idle.put(worker)
|
||||||
|
for _ in range(self.size):
|
||||||
|
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
quality: int,
|
||||||
|
maxsize: int,
|
||||||
|
maxzoom: float,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
|
if self._closed:
|
||||||
|
raise PreviewError("preview worker pool closed")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
future = loop.create_future()
|
||||||
|
self._seq += 1
|
||||||
|
await self._pending.put(
|
||||||
|
(
|
||||||
|
_preview_job_priority(filepath),
|
||||||
|
self._seq,
|
||||||
|
future,
|
||||||
|
(filepath, quality, maxsize, maxzoom, data),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
for task in self._dispatchers:
|
||||||
|
task.cancel()
|
||||||
|
if self._dispatchers:
|
||||||
|
await asyncio.gather(*self._dispatchers, return_exceptions=True)
|
||||||
|
self._dispatchers.clear()
|
||||||
|
workers = list(self._workers)
|
||||||
|
self._workers.clear()
|
||||||
|
while not self._pending.empty():
|
||||||
|
try:
|
||||||
|
_priority, _seq, future, _args = self._pending.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(PreviewError("preview worker pool closed"))
|
||||||
|
while not self._idle.empty():
|
||||||
|
try:
|
||||||
|
self._idle.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
await asyncio.gather(
|
||||||
|
*(worker.kill() for worker in workers), return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_preview_workers() -> None:
|
||||||
|
"""Warm up persistent preview workers during server startup."""
|
||||||
|
global _preview_pool
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
|
||||||
|
await pool.start()
|
||||||
|
_preview_pool = pool
|
||||||
|
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_preview_workers() -> None:
|
||||||
|
"""Kill persistent preview workers (called during server shutdown)."""
|
||||||
|
global _preview_pool
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
pool = _preview_pool
|
||||||
|
_preview_pool = None
|
||||||
|
if pool is not None:
|
||||||
|
await pool.close()
|
||||||
|
if not _active_procs:
|
||||||
|
return
|
||||||
|
for proc in list(_active_procs):
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
proc.kill()
|
||||||
|
await asyncio.gather(
|
||||||
|
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
|
||||||
|
)
|
||||||
|
_active_procs.clear()
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_preview(request):
|
async def verify_preview(request):
|
||||||
@@ -77,16 +407,155 @@ async def verify_preview(request):
|
|||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
class PreviewTimeoutError(Exception):
|
||||||
EXIF_ORI = {
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
|
||||||
3: Image.Transpose.ROTATE_180,
|
|
||||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
class PreviewError(Exception):
|
||||||
5: Image.Transpose.TRANSPOSE,
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
6: Image.Transpose.ROTATE_270,
|
|
||||||
7: Image.Transpose.TRANSVERSE,
|
def __init__(
|
||||||
8: Image.Transpose.ROTATE_90,
|
self,
|
||||||
}
|
message: str,
|
||||||
|
*,
|
||||||
|
stderr: str | None = None,
|
||||||
|
backend: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.stderr = stderr
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
# Max concurrent OnlyOffice conversion requests. OO has its own queue;
|
||||||
|
# we must not flood it. This is intentionally small.
|
||||||
|
OO_MAX_CONCURRENT = PREVIEW_WORKERS
|
||||||
|
|
||||||
|
|
||||||
|
class OOConversionManager:
|
||||||
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
||||||
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
|
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
||||||
|
stat = await asyncio.to_thread(filepath.stat)
|
||||||
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._in_flight:
|
||||||
|
future = self._in_flight[key]
|
||||||
|
else:
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._in_flight[key] = future
|
||||||
|
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _do_convert(
|
||||||
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with self._semaphore:
|
||||||
|
png_bytes = await onlyoffice.convert_to_png_async(
|
||||||
|
filepath, request_timeout=5.0
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
future.set_exception(e)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
else:
|
||||||
|
future.set_result(png_bytes)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_manager: OOConversionManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_oo_manager() -> OOConversionManager:
|
||||||
|
"""Return the singleton OOConversionManager."""
|
||||||
|
global _oo_manager
|
||||||
|
if _oo_manager is None:
|
||||||
|
_oo_manager = OOConversionManager(max_concurrent=OO_MAX_CONCURRENT)
|
||||||
|
return _oo_manager
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_office_preview(
|
||||||
|
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
|
||||||
|
manager = get_oo_manager()
|
||||||
|
t_oo_start = perf_counter()
|
||||||
|
png_bytes = await manager.convert(filepath)
|
||||||
|
t_oo_end = perf_counter()
|
||||||
|
|
||||||
|
img, resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom, data=png_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp is not None:
|
||||||
|
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
|
||||||
|
if resp.timings:
|
||||||
|
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
||||||
|
return img, resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_preview_process(
|
||||||
|
filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Run preview request in a persistent worker process."""
|
||||||
|
await start_preview_workers()
|
||||||
|
if _preview_pool is None:
|
||||||
|
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
||||||
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
||||||
|
|
||||||
|
|
||||||
|
def _onlyoffice_error_short_text(detail: str) -> str:
|
||||||
|
if detail.startswith("OnlyOffice conversion error:"):
|
||||||
|
code = detail.rsplit(":", 1)[-1].strip()
|
||||||
|
return {
|
||||||
|
"-8": "onlyoffice jwt error",
|
||||||
|
"-4": "onlyoffice input error",
|
||||||
|
"-2": "onlyoffice timeout error",
|
||||||
|
"-1": "onlyoffice unknown error",
|
||||||
|
}.get(code, f"onlyoffice {code} error")
|
||||||
|
if "OnlyOffice response did not contain FileUrl" in detail:
|
||||||
|
return "onlyoffice no-fileurl error"
|
||||||
|
return "onlyoffice error"
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_job_priority(path) -> int:
|
||||||
|
"""Return priority for preview job (lower=higher priority).
|
||||||
|
|
||||||
|
Priority order: images (0) < video (1) < PDF (2) < office (3) < unknown (4)
|
||||||
|
"""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return 2
|
||||||
|
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return 3
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
return 0
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
return 1
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def is_previewable_path(path) -> bool:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return True
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if not mime_type:
|
||||||
|
return False
|
||||||
|
return mime_type.startswith(("image/", "video/"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<path:path>")
|
@bp.get("/<path:path>")
|
||||||
@@ -95,12 +564,23 @@ async def preview(req, path):
|
|||||||
maxsize = int(req.args.get("px", 1024))
|
maxsize = int(req.args.get("px", 1024))
|
||||||
maxzoom = float(req.args.get("zoom", 2.0))
|
maxzoom = float(req.args.get("zoom", 2.0))
|
||||||
quality = int(req.args.get("q", 60))
|
quality = int(req.args.get("q", 60))
|
||||||
rel = PurePosixPath(sanitize(unquote(path)))
|
share_token = auth.request_share_token(req)
|
||||||
filepath = config.config.path / rel
|
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:
|
try:
|
||||||
stat = filepath.lstat()
|
stat = filepath.lstat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise NotFound() from None
|
raise NotFound from None
|
||||||
|
|
||||||
|
if not is_previewable_path(filepath):
|
||||||
|
return empty(415)
|
||||||
|
|
||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
@@ -117,257 +597,78 @@ async def preview(req, path):
|
|||||||
return raw(cached.body, headers=cached.headers)
|
return raw(cached.body, headers=cached.headers)
|
||||||
|
|
||||||
# Generate preview
|
# Generate preview
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
try:
|
||||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES:
|
||||||
)
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_generate_office_preview(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_run_preview_process(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning("Preview timeout for %s", filepath)
|
||||||
|
return empty(503)
|
||||||
|
except PreviewTimeoutError:
|
||||||
|
logger.warning("Preview worker timeout for %s", filepath)
|
||||||
|
return empty(503)
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
req.ctx.log_extra = "onlyoffice N/A"
|
||||||
|
return empty(503)
|
||||||
|
except httpx.RequestError:
|
||||||
|
req.ctx.log_extra = "onlyoffice N/A"
|
||||||
|
return empty(503)
|
||||||
|
except RuntimeError as e:
|
||||||
|
detail = str(e)
|
||||||
|
if detail.startswith("OnlyOffice"):
|
||||||
|
req.ctx.log_extra = _onlyoffice_error_short_text(detail)
|
||||||
|
return empty(503)
|
||||||
|
raise
|
||||||
|
except PreviewError as e:
|
||||||
|
if e.backend:
|
||||||
|
req.ctx.log_extra = e.backend
|
||||||
|
detail = str(e)
|
||||||
|
if detail == "preview worker error" and e.stderr:
|
||||||
|
captured = e.stderr.strip()
|
||||||
|
if captured:
|
||||||
|
detail = captured.splitlines()[0]
|
||||||
|
logger.error("%s preview: %s", filepath, detail)
|
||||||
|
return empty(422)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
req.ctx.log_extra = "preview cancelled"
|
||||||
|
return empty(503)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled preview error for %s", filepath)
|
||||||
|
return empty(500)
|
||||||
|
if preview_resp and preview_resp.backend:
|
||||||
|
if preview_resp.timings:
|
||||||
|
timing_detail = "/".join(
|
||||||
|
str(round(value)) for value in preview_resp.timings
|
||||||
|
)
|
||||||
|
req.ctx.log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||||
|
else:
|
||||||
|
req.ctx.log_extra = preview_resp.backend
|
||||||
if not img:
|
if not img:
|
||||||
# Preview generation failed, redirect to the file itself
|
# Preview generation failed, redirect to the file itself
|
||||||
return redirect(f"/files/{path}", status=303)
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
# Build headers and cache the full response
|
# 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")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": format_date_time(stat.st_mtime),
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
"cache-control": "max-age=604800, immutable"
|
"cache-control": "max-age=604800, immutable"
|
||||||
+ ("" if config.config.public else ", private"),
|
+ ("" if config.config.public else ", private"),
|
||||||
"content-type": "image/avif",
|
"content-type": preview_mime,
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
|
||||||
try:
|
|
||||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
|
||||||
type, _ = mimetypes.guess_type(path.name)
|
|
||||||
if type and type.startswith("video/"):
|
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(f"Cannot generate preview for {path}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error generating preview for {path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
|
||||||
try:
|
|
||||||
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Falling back to Pillow preview for %s: %s", path.name, e)
|
|
||||||
return process_image_pillow(path, maxsize=maxsize, quality=quality)
|
|
||||||
|
|
||||||
|
|
||||||
def process_image_pyvips(path, *, maxsize, quality):
|
|
||||||
import pyvips
|
|
||||||
|
|
||||||
t_load = perf_counter()
|
|
||||||
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
|
||||||
t_proc = perf_counter()
|
|
||||||
|
|
||||||
img = img.autorot()
|
|
||||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
|
||||||
if scale < 1.0:
|
|
||||||
img = img.resize(scale)
|
|
||||||
|
|
||||||
t_save = perf_counter()
|
|
||||||
ret = img.write_to_buffer(".avif", Q=quality)
|
|
||||||
t_end = perf_counter()
|
|
||||||
|
|
||||||
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 via pyvips: load=%.1fms process=%.1fms save=%.1fms",
|
|
||||||
path.name,
|
|
||||||
load_ms,
|
|
||||||
proc_ms,
|
|
||||||
save_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def process_image_pillow(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,
|
|
||||||
avif=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 via Pillow: load=%.1fms process=%.1fms save=%.1fms",
|
|
||||||
path.name,
|
|
||||||
load_ms,
|
|
||||||
proc_ms,
|
|
||||||
save_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|
||||||
t_load_start = perf_counter()
|
|
||||||
pdf = fitz.open(path)
|
|
||||||
page = pdf.load_page(page_number)
|
|
||||||
w, h = page.rect[2:4]
|
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
|
||||||
mat = fitz.Matrix(zoom, zoom)
|
|
||||||
pix = page.get_pixmap(matrix=mat)
|
|
||||||
t_load_end = perf_counter()
|
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
|
||||||
ret = pix.pil_tobytes(
|
|
||||||
format="avif", quality=quality, speed=10, max_threads=1, avif=1
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def process_video(path, *, maxsize, quality):
|
|
||||||
frame = None
|
|
||||||
imgdata = io.BytesIO()
|
|
||||||
istream = ostream = icc = occ = frame = None
|
|
||||||
t_load_start = perf_counter()
|
|
||||||
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
|
||||||
t_load_end = t_load_start
|
|
||||||
t_save_start = t_load_start
|
|
||||||
t_save_end = t_load_start
|
|
||||||
with (
|
|
||||||
av.open(str(path)) as icontainer,
|
|
||||||
av.open(imgdata, "w", format="avif") as ocontainer,
|
|
||||||
):
|
|
||||||
istream = icontainer.streams.video[0]
|
|
||||||
istream.codec_context.skip_frame = "NONKEY"
|
|
||||||
icontainer.seek((icontainer.duration or 0) // 8)
|
|
||||||
for frame in icontainer.decode(istream):
|
|
||||||
if frame.dts is not None:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise RuntimeError("No frames found in video")
|
|
||||||
|
|
||||||
# Resize frame to thumbnail size
|
|
||||||
if frame.width > maxsize or frame.height > maxsize:
|
|
||||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
|
||||||
new_width = int(frame.width * scale_factor)
|
|
||||||
new_height = int(frame.height * scale_factor)
|
|
||||||
frame = frame.reformat(width=new_width, height=new_height)
|
|
||||||
|
|
||||||
# Apply EXIF rotation if present
|
|
||||||
if frame.rotation:
|
|
||||||
# frame.rotation indicates clockwise rotation needed to display correctly
|
|
||||||
# np.rot90 rotates counter-clockwise, so we negate k
|
|
||||||
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
|
||||||
if k == 2:
|
|
||||||
# 180° rotation can be done in YUV420p, preserving HDR
|
|
||||||
try:
|
|
||||||
fplanes = frame.to_ndarray()
|
|
||||||
# Split into Y, U, V planes of proper dimensions
|
|
||||||
planes = [
|
|
||||||
fplanes[: frame.height],
|
|
||||||
fplanes[
|
|
||||||
frame.height : frame.height + frame.height // 4
|
|
||||||
].reshape(frame.height // 2, frame.width // 2),
|
|
||||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
|
||||||
frame.height // 2, frame.width // 2
|
|
||||||
),
|
|
||||||
]
|
|
||||||
# Rotate each plane by 180°
|
|
||||||
planes = [np.rot90(p, 2) for p in planes]
|
|
||||||
# Restore PyAV format
|
|
||||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
|
||||||
-1, planes[0].shape[1]
|
|
||||||
)
|
|
||||||
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
|
||||||
del planes, fplanes
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error rotating video frame by 180°: {e}")
|
|
||||||
elif k in (1, 3):
|
|
||||||
# 90° or 270° rotation requires RGB conversion (loses HDR)
|
|
||||||
try:
|
|
||||||
rgb = frame.to_ndarray(format="rgb24")
|
|
||||||
rgb = np.rot90(rgb, k)
|
|
||||||
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
|
|
||||||
frame = frame.reformat(
|
|
||||||
format="yuv420p"
|
|
||||||
) # Convert back for encoding
|
|
||||||
del rgb
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Error rotating video frame by {frame.rotation}°: {e}"
|
|
||||||
)
|
|
||||||
t_load_end = perf_counter()
|
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
|
||||||
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
|
||||||
ostream = ocontainer.add_stream(
|
|
||||||
"av1",
|
|
||||||
options={
|
|
||||||
"crf": crf,
|
|
||||||
"usage": "realtime",
|
|
||||||
"cpu-used": "8",
|
|
||||||
"threads": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert isinstance(ostream, av.VideoStream)
|
|
||||||
ostream.width = frame.width
|
|
||||||
ostream.height = frame.height
|
|
||||||
ostream.pix_fmt = frame.format.name
|
|
||||||
icc = istream.codec_context
|
|
||||||
occ = ostream.codec_context
|
|
||||||
|
|
||||||
# Copy HDR metadata from input video stream
|
|
||||||
occ.color_primaries = icc.color_primaries
|
|
||||||
occ.color_trc = icc.color_trc
|
|
||||||
occ.colorspace = icc.colorspace
|
|
||||||
occ.color_range = icc.color_range
|
|
||||||
|
|
||||||
ocontainer.mux(ostream.encode(frame))
|
|
||||||
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
|
||||||
t_save_end = perf_counter()
|
|
||||||
|
|
||||||
# Capture frame dimensions 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,
|
|
||||||
)
|
|
||||||
del imgdata, istream, ostream, icc, occ, frame
|
|
||||||
gc.collect()
|
|
||||||
return ret
|
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""Preview generation worker subprocess and synchronous preview engine.
|
||||||
|
|
||||||
|
Two modes are supported:
|
||||||
|
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||||
|
2) Long-lived mode: read framed requests from stdin and write framed responses.
|
||||||
|
|
||||||
|
Framed request format (stdin):
|
||||||
|
(uint32 json size)(uint32 data size)(json)(binary data)
|
||||||
|
|
||||||
|
Framed response format (stdout):
|
||||||
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import gc
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import av
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
import msgspec
|
||||||
|
import numpy as np
|
||||||
|
import pyvips
|
||||||
|
from blake3 import blake3
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AVIF_FAST_EFFORT = 0
|
||||||
|
|
||||||
|
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||||
|
|
||||||
|
OFFICE_PREVIEW_SUFFIXES = {
|
||||||
|
".doc",
|
||||||
|
".dot",
|
||||||
|
".docx",
|
||||||
|
".docm",
|
||||||
|
".dotx",
|
||||||
|
".dotm",
|
||||||
|
".rtf",
|
||||||
|
".odt",
|
||||||
|
".ott",
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".mhtml",
|
||||||
|
".mht",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".xml",
|
||||||
|
".wps",
|
||||||
|
".wri",
|
||||||
|
# Spreadsheets
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".xlsm",
|
||||||
|
".xlsb",
|
||||||
|
".xltx",
|
||||||
|
".xltm",
|
||||||
|
".ods",
|
||||||
|
".ots",
|
||||||
|
".csv",
|
||||||
|
# Presentations
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".pptm",
|
||||||
|
".pps",
|
||||||
|
".ppsx",
|
||||||
|
".pot",
|
||||||
|
".potx",
|
||||||
|
".odp",
|
||||||
|
".otp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
||||||
|
path: str
|
||||||
|
quality: int
|
||||||
|
maxsize: int
|
||||||
|
maxzoom: float
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||||
|
ok: bool
|
||||||
|
mime: str | None = None
|
||||||
|
backend: str | None = None
|
||||||
|
timings: list[float] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_enc = msgspec.json.Encoder()
|
||||||
|
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exactly(f, n: int) -> bytes:
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = f.read(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
||||||
|
try:
|
||||||
|
header = _read_exactly(sys.stdin.buffer, 8)
|
||||||
|
except EOFError:
|
||||||
|
return None
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
meta_raw = _read_exactly(sys.stdin.buffer, json_size)
|
||||||
|
data = b""
|
||||||
|
if data_size:
|
||||||
|
data = _read_exactly(sys.stdin.buffer, data_size)
|
||||||
|
req = _dec_req.decode(meta_raw)
|
||||||
|
return req, data
|
||||||
|
|
||||||
|
|
||||||
|
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 dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||||
|
backend = "unknown"
|
||||||
|
try:
|
||||||
|
if data:
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image_buffer(
|
||||||
|
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
||||||
|
)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
backend = "pdf"
|
||||||
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
backend = "video"
|
||||||
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
except ValueError as e:
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Preview dispatch failed for %s", path)
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(path, *, maxsize, quality):
|
||||||
|
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
|
||||||
|
"""Probe image dimensions.
|
||||||
|
|
||||||
|
pyvips can read the header of most formats (including HEIC) without
|
||||||
|
fully decoding the image.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_file(str(path))
|
||||||
|
except pyvips.error.Error:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return img.width, img.height
|
||||||
|
|
||||||
|
|
||||||
|
def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||||
|
"""Convert any image to AVIF using ffmpeg CLI.
|
||||||
|
|
||||||
|
ffmpeg handles HEIC tile assembly, EXIF rotation, HDR metadata and
|
||||||
|
ICC profile embedding automatically.
|
||||||
|
"""
|
||||||
|
dims = _get_image_dimensions(path)
|
||||||
|
crf = int(63 * (1 - quality / 100) ** 2)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".avif", delete=False) as tmp_f:
|
||||||
|
tmp_path = tmp_f.name
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(path),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-c:v",
|
||||||
|
"av1",
|
||||||
|
"-crf",
|
||||||
|
str(crf),
|
||||||
|
"-cpu-used",
|
||||||
|
"8",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
if dims is not None:
|
||||||
|
w, h = dims
|
||||||
|
if max(w, h) > maxsize:
|
||||||
|
scale = min(maxsize / w, maxsize / h)
|
||||||
|
new_w = int(w * scale)
|
||||||
|
new_h = int(h * scale)
|
||||||
|
# insert -s <wxh> right after the input file
|
||||||
|
cmd.insert(4, "-s")
|
||||||
|
cmd.insert(5, f"{new_w}x{new_h}")
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
||||||
|
with Path(tmp_path).open("rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_pyvips(path, *, maxsize, quality):
|
||||||
|
t_start = perf_counter()
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
|
||||||
|
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||||
|
# skip pyvips entirely.
|
||||||
|
if suffix in (".heic", ".heif"):
|
||||||
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
|
t_end = perf_counter()
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="ffmpeg",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other image formats: pyvips first, ffmpeg fallback.
|
||||||
|
load_opts = {"access": "sequential"}
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
backend = "pyvips"
|
||||||
|
except pyvips.error.Error:
|
||||||
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
|
backend = "ffmpeg"
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend=backend,
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||||
|
_ = maxzoom
|
||||||
|
t_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_buffer(data, "")
|
||||||
|
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, 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):
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
pdf = fitz.open(path)
|
||||||
|
page = pdf.load_page(page_number)
|
||||||
|
w, h = page.rect[2:4]
|
||||||
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_memory(
|
||||||
|
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||||
|
)
|
||||||
|
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
|
||||||
|
backend = "pdf+pyvips"
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend=backend,
|
||||||
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(path, *, maxsize, quality):
|
||||||
|
frame = None
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
istream = ostream = icc = occ = frame = None
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
||||||
|
t_load_end = t_load_start
|
||||||
|
t_save_start = t_load_start
|
||||||
|
t_save_end = t_load_start
|
||||||
|
with (
|
||||||
|
av.open(
|
||||||
|
str(path),
|
||||||
|
options={
|
||||||
|
"analyzeduration": "1000000", # 1 second (in microseconds)
|
||||||
|
"fflags": "fastseek",
|
||||||
|
},
|
||||||
|
) as icontainer,
|
||||||
|
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||||
|
):
|
||||||
|
istream = icontainer.streams.video[0]
|
||||||
|
istream.codec_context.skip_frame = "NONKEY"
|
||||||
|
icontainer.seek((icontainer.duration or 0) // 8)
|
||||||
|
for frame in icontainer.decode(istream):
|
||||||
|
if frame.dts is not None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
|
# Resize frame to thumbnail size
|
||||||
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
|
new_width = int(frame.width * scale_factor)
|
||||||
|
new_height = int(frame.height * scale_factor)
|
||||||
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
|
# Apply EXIF rotation if present
|
||||||
|
if frame.rotation:
|
||||||
|
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||||
|
# np.rot90 rotates counter-clockwise, so we negate k
|
||||||
|
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||||
|
if k == 2:
|
||||||
|
# 180° rotation can be done in YUV420p, preserving HDR
|
||||||
|
try:
|
||||||
|
fplanes = frame.to_ndarray()
|
||||||
|
# Split into Y, U, V planes of proper dimensions
|
||||||
|
planes = [
|
||||||
|
fplanes[: frame.height],
|
||||||
|
fplanes[
|
||||||
|
frame.height : frame.height + frame.height // 4
|
||||||
|
].reshape(frame.height // 2, frame.width // 2),
|
||||||
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
|
frame.height // 2, frame.width // 2
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Rotate each plane by 180°
|
||||||
|
planes = [np.rot90(p, 2) for p in planes]
|
||||||
|
# Restore PyAV format
|
||||||
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
|
-1, planes[0].shape[1]
|
||||||
|
)
|
||||||
|
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||||
|
del planes, fplanes
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error rotating video frame by 180°")
|
||||||
|
elif k in (1, 3):
|
||||||
|
# 90° or 270° rotation requires RGB conversion (loses HDR)
|
||||||
|
try:
|
||||||
|
rgb = frame.to_ndarray(format="rgb24")
|
||||||
|
rgb = np.rot90(rgb, k)
|
||||||
|
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
|
||||||
|
frame = frame.reformat(
|
||||||
|
format="yuv420p"
|
||||||
|
) # Convert back for encoding
|
||||||
|
del rgb
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Error rotating video frame by %s°", frame.rotation
|
||||||
|
)
|
||||||
|
|
||||||
|
# libsvtav1 rejects full-range JPEG-style YUV pixel formats such as
|
||||||
|
# yuvj420p, so normalize them before opening the encoder.
|
||||||
|
if frame.format.name.startswith("yuvj"):
|
||||||
|
frame = frame.reformat(format="yuv420p")
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
||||||
|
ostream = ocontainer.add_stream(
|
||||||
|
"av1",
|
||||||
|
options={
|
||||||
|
"crf": crf,
|
||||||
|
"usage": "realtime",
|
||||||
|
"cpu-used": "8",
|
||||||
|
"threads": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(ostream, av.VideoStream):
|
||||||
|
raise TypeError("failed to initialize AV1 video stream")
|
||||||
|
ostream.width = frame.width
|
||||||
|
ostream.height = frame.height
|
||||||
|
ostream.pix_fmt = frame.format.name
|
||||||
|
icc = istream.codec_context
|
||||||
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
# Copy HDR metadata from input video stream
|
||||||
|
occ.color_primaries = icc.color_primaries
|
||||||
|
occ.color_trc = icc.color_trc
|
||||||
|
occ.colorspace = icc.colorspace
|
||||||
|
occ.color_range = icc.color_range
|
||||||
|
|
||||||
|
ocontainer.mux(ostream.encode(frame))
|
||||||
|
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
# Capture result before cleanup
|
||||||
|
ret = imgdata.getvalue()
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="video",
|
||||||
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
|
gc.collect()
|
||||||
|
return ret, resp
|
||||||
|
|
||||||
|
|
||||||
|
def _run_once() -> None:
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
quality = int(sys.argv[2])
|
||||||
|
maxsize = int(sys.argv[3])
|
||||||
|
maxzoom = float(sys.argv[4])
|
||||||
|
result, _ = dispatch(path, quality, maxsize, maxzoom)
|
||||||
|
if result:
|
||||||
|
sys.stdout.buffer.write(result)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop() -> None:
|
||||||
|
while True:
|
||||||
|
result = _read_request()
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
req, data = result
|
||||||
|
stderr_capture = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(stderr_capture)
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stderr(stderr_capture):
|
||||||
|
result, resp = dispatch(
|
||||||
|
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
if captured:
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=False,
|
||||||
|
backend=resp.backend,
|
||||||
|
error=resp.error,
|
||||||
|
stderr=captured,
|
||||||
|
)
|
||||||
|
_write_response(resp, result or b"")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Preview worker error for %s", req.path)
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
_write_response(
|
||||||
|
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
root_logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Configure all log output to stderr before any imports that may emit logs.
|
||||||
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||||
|
try:
|
||||||
|
config.load_config()
|
||||||
|
logger.warning(
|
||||||
|
"preview-worker config=%s master_secret=%s",
|
||||||
|
config.conffile,
|
||||||
|
config.config.secret,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("preview-worker failed to load config at startup")
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
_run_once()
|
||||||
|
return
|
||||||
|
# Eagerly import heavy modules before signalling readiness so the parent
|
||||||
|
# does not hand us a request while we are still initialising.
|
||||||
|
sys.stdout.buffer.write(b"\x01")
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
_run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,136 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import BadRequest
|
|
||||||
|
|
||||||
from cista import config
|
|
||||||
from cista.util import filename
|
|
||||||
|
|
||||||
## Control commands
|
|
||||||
|
|
||||||
|
|
||||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
|
||||||
def __call__(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
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):
|
class ErrorMsg(msgspec.Struct):
|
||||||
|
|||||||
+82
-22
@@ -5,21 +5,23 @@ import sys
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from ipaddress import IPv6Address
|
from ipaddress import IPv6Address
|
||||||
|
|
||||||
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||||
|
|
||||||
logger = logging.getLogger("cista.access")
|
logger = logging.getLogger("cista.access")
|
||||||
|
|
||||||
_RESET = "\033[0m"
|
_RESET = "\033[0m"
|
||||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||||
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||||
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||||
_PATH = "\033[38;5;250m" # path (light grey)
|
_PATH = "\033[38;5;250m" # path (light grey)
|
||||||
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||||
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,8 +57,8 @@ def format_client_ip(ip: str) -> str:
|
|||||||
return "-"
|
return "-"
|
||||||
stripped = ip.strip("[]")
|
stripped = ip.strip("[]")
|
||||||
if ":" in stripped:
|
if ":" in stripped:
|
||||||
return format_ipv6_network(ip)
|
return format_ipv6_network(stripped)
|
||||||
return ip
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
def status_color(status: int) -> str:
|
def status_color(status: int) -> str:
|
||||||
@@ -96,10 +98,11 @@ def format_duration_ms(duration_ms: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _display_width(text: str) -> int:
|
def _display_width(text: str) -> int:
|
||||||
width = 0
|
return sum(
|
||||||
for char in text:
|
1 + (unicodedata.east_asian_width(c) in "FW")
|
||||||
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
|
for c in text
|
||||||
return width
|
if unicodedata.category(c) != "Mn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _format_left(label: str) -> str:
|
def _format_left(label: str) -> str:
|
||||||
@@ -113,7 +116,13 @@ def _format_method_label(label: str, *, color: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def format_access_log(
|
def format_access_log(
|
||||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
client: str,
|
||||||
|
status: int,
|
||||||
|
method: str,
|
||||||
|
host: str,
|
||||||
|
path: str,
|
||||||
|
duration_ms: float,
|
||||||
|
extra: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
ip = _format_left(format_client_ip(client))
|
ip = _format_left(format_client_ip(client))
|
||||||
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
|
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
|
||||||
@@ -121,7 +130,10 @@ def format_access_log(
|
|||||||
host_str = f"{_HOST}{host}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
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
|
_ws_counter = 1
|
||||||
@@ -144,7 +156,7 @@ def log_ws_open(request, extra: str | None = None) -> int:
|
|||||||
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
|
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
|
||||||
ws_id = _next_ws_id()
|
ws_id = _next_ws_id()
|
||||||
|
|
||||||
client = request.ip or "-"
|
client = request.client_ip or "-"
|
||||||
host = request.host or "-"
|
host = request.host or "-"
|
||||||
path = request.path
|
path = request.path
|
||||||
origin = request.headers.get("origin")
|
origin = request.headers.get("origin")
|
||||||
@@ -192,7 +204,9 @@ WS_CLOSE_CODES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
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."""
|
"""Log WebSocket connection close with duration and status."""
|
||||||
id_str = _format_ws_id(ws_id)
|
id_str = _format_ws_id(ws_id)
|
||||||
timing = format_duration_ms(duration * 1000)
|
timing = format_duration_ms(duration * 1000)
|
||||||
@@ -207,8 +221,17 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|||||||
method_str = _format_method_label("closed", color=_TIMING)
|
method_str = _format_method_label("closed", color=_TIMING)
|
||||||
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
|
||||||
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
|
logger.info(
|
||||||
|
"%s %s %s %s %s%s",
|
||||||
|
" " * 19,
|
||||||
|
id_str,
|
||||||
|
method_str,
|
||||||
|
status_str,
|
||||||
|
timing_str,
|
||||||
|
extra_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_access_logging() -> None:
|
def configure_access_logging() -> None:
|
||||||
@@ -218,3 +241,40 @@ def configure_access_logging() -> None:
|
|||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_LEVEL_EMOJI = {
|
||||||
|
logging.DEBUG: "🔍",
|
||||||
|
logging.INFO: "ℹ️", # noqa: RUF001
|
||||||
|
logging.WARNING: "⚠️",
|
||||||
|
logging.ERROR: "🛑",
|
||||||
|
logging.CRITICAL: "🛑",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_level_prefix(levelno: int) -> str:
|
||||||
|
emoji = _LEVEL_EMOJI.get(levelno, "▪️")
|
||||||
|
prefix = f"{emoji} "
|
||||||
|
return prefix + (" " * max(0, 3 - _display_width(prefix)))
|
||||||
|
|
||||||
|
|
||||||
|
class _EmojiFormatter(logging.Formatter):
|
||||||
|
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
return _format_level_prefix(record.levelno) + record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
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().
|
||||||
|
"""
|
||||||
|
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
-6
@@ -6,13 +6,12 @@ from fastapi_vue.hostutil import parse_endpoint
|
|||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
|
from cista.app import app
|
||||||
|
|
||||||
|
|
||||||
def run(*, dev=False):
|
def run(*, dev=False):
|
||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
from .app import app
|
_url, opts = parse_listen(config.config.listen)
|
||||||
|
|
||||||
url, opts = parse_listen(config.config.listen)
|
|
||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
confdir = config.conffile.parent
|
confdir = config.conffile.parent
|
||||||
@@ -21,14 +20,14 @@ def run(*, dev=False):
|
|||||||
server80.app.prepare(port=80, motd=False)
|
server80.app.prepare(port=80, motd=False)
|
||||||
domain = opts["host"]
|
domain = opts["host"]
|
||||||
check_cert(confdir / domain, domain)
|
check_cert(confdir / domain, domain)
|
||||||
opts["ssl"] = str(confdir / domain) # type: ignore
|
opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
|
||||||
app.prepare(
|
app.prepare(
|
||||||
**opts,
|
**opts,
|
||||||
motd=False,
|
motd=False,
|
||||||
dev=dev,
|
dev=dev,
|
||||||
auto_reload=dev,
|
auto_reload=dev,
|
||||||
access_log=False,
|
access_log=False,
|
||||||
) # type: ignore
|
) # type: ignore[call-arg]
|
||||||
if dev:
|
if dev:
|
||||||
Sanic.serve()
|
Sanic.serve()
|
||||||
else:
|
else:
|
||||||
@@ -36,9 +35,10 @@ def run(*, dev=False):
|
|||||||
|
|
||||||
|
|
||||||
def check_cert(certdir, domain):
|
def check_cert(certdir, domain):
|
||||||
|
_ = domain
|
||||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||||
return
|
return
|
||||||
# TODO: Use certbot to fetch a cert
|
# Certificate provisioning is external; files must exist before startup.
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app = Sanic("server80")
|
|||||||
# Send all HTTP users to HTTPS
|
# Send all HTTP users to HTTPS
|
||||||
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
||||||
def redirect_everything_else(request, exception):
|
def redirect_everything_else(request, exception):
|
||||||
|
_ = exception
|
||||||
server, path = request.server_name, request.path
|
server, path = request.server_name, request.path
|
||||||
if server and path.startswith("/"):
|
if server and path.startswith("/"):
|
||||||
return response.redirect(f"https://{server}{path}", status=308)
|
return response.redirect(f"https://{server}{path}", status=308)
|
||||||
@@ -15,6 +16,7 @@ def redirect_everything_else(request, exception):
|
|||||||
# ACME challenge for LetsEncrypt
|
# ACME challenge for LetsEncrypt
|
||||||
@app.get("/.well-known/acme-challenge/<challenge>")
|
@app.get("/.well-known/acme-challenge/<challenge>")
|
||||||
async def letsencrypt(request, challenge):
|
async def letsencrypt(request, challenge):
|
||||||
|
_ = request
|
||||||
try:
|
try:
|
||||||
return response.text(acme_challenges[challenge])
|
return response.text(acme_challenges[challenge])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
+46
-26
@@ -1,43 +1,63 @@
|
|||||||
|
import secrets
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
import jwt
|
# In-memory session store: token -> {"username": str, "exp": int}
|
||||||
|
_sessions: dict[str, dict] = {}
|
||||||
from cista.config import derived_secret
|
|
||||||
|
|
||||||
|
|
||||||
def session_secret():
|
|
||||||
return derived_secret("session")
|
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "cista"
|
||||||
|
|
||||||
max_age = 365 * 86400 # Seconds since last login
|
max_age = 365 * 86400 # Seconds since last login
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
return secrets.token_urlsafe(8)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired() -> None:
|
||||||
|
now = time()
|
||||||
|
expired = [t for t, s in _sessions.items() if s["exp"] <= now]
|
||||||
|
for t in expired:
|
||||||
|
del _sessions[t]
|
||||||
|
|
||||||
|
|
||||||
def get(request):
|
def get(request):
|
||||||
try:
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
if token is None:
|
||||||
except Exception:
|
return None
|
||||||
return False if "s" in request.cookies else None
|
s = _sessions.get(token)
|
||||||
|
if s is None:
|
||||||
|
return False # Cookie present but session not found / expired
|
||||||
|
if s["exp"] <= time():
|
||||||
|
del _sessions[token]
|
||||||
|
return False
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def create(res, username, **kwargs):
|
def create(request, res, username, **kwargs):
|
||||||
data = {
|
_purge_expired()
|
||||||
"exp": int(time()) + max_age,
|
token = _token()
|
||||||
"username": username,
|
put(token, username, **kwargs)
|
||||||
**kwargs,
|
secure = request.scheme == "https"
|
||||||
}
|
res.cookies.add_cookie(
|
||||||
s = jwt.encode(data, session_secret())
|
SESSION_COOKIE_NAME,
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=max_age,
|
||||||
|
secure=secure,
|
||||||
|
host_prefix=secure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update(res, s, **kwargs):
|
def delete(request, res):
|
||||||
s.update(kwargs)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
s = jwt.encode(s, session_secret())
|
if token is not None:
|
||||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
_sessions.pop(token, None)
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
secure = request.scheme == "https"
|
||||||
|
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
|
||||||
|
|
||||||
|
|
||||||
def delete(res):
|
def put(token: str, username: str, **kwargs) -> None:
|
||||||
res.cookies.delete_cookie("s")
|
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
|
||||||
|
|
||||||
|
|
||||||
def flash(res, message: str | None):
|
def flash(res, message: str | None):
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from stat import S_ISDIR, S_ISREG
|
||||||
|
from time import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from natsort import humansorted
|
||||||
|
from sanic.exceptions import BadRequest, NotFound
|
||||||
|
|
||||||
|
from cista import config, watching
|
||||||
|
from cista.fileio import fuid
|
||||||
|
from cista.protocol import FileEntry
|
||||||
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRootEntry(NamedTuple):
|
||||||
|
alias: str
|
||||||
|
real_rel: PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
def _token_is_share(token: config.Token) -> bool:
|
||||||
|
return token.kind == "share" and bool(token.share_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def is_share_token(token: config.Token | None) -> bool:
|
||||||
|
return bool(token and _token_is_share(token))
|
||||||
|
|
||||||
|
|
||||||
|
def build_share_roots(token: config.Token) -> list[ShareRootEntry]:
|
||||||
|
if not _token_is_share(token):
|
||||||
|
return []
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
roots: list[ShareRootEntry] = []
|
||||||
|
used_aliases: set[str] = set()
|
||||||
|
|
||||||
|
for raw_path in token.share_paths:
|
||||||
|
try:
|
||||||
|
clean = sanitize(raw_path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel = PurePosixPath(clean)
|
||||||
|
resolved = (base / rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base) or not resolved.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
display = rel.name or config.config.path.name
|
||||||
|
alias = display
|
||||||
|
suffix = 2
|
||||||
|
while alias in used_aliases:
|
||||||
|
alias = f"{display} ({suffix})"
|
||||||
|
suffix += 1
|
||||||
|
used_aliases.add(alias)
|
||||||
|
roots.append(ShareRootEntry(alias=alias, real_rel=rel))
|
||||||
|
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_virtual_path(
|
||||||
|
token: config.Token,
|
||||||
|
raw_path: str,
|
||||||
|
) -> tuple[PurePosixPath, PurePosixPath, Path, bool]:
|
||||||
|
"""Resolve a share-virtual path to real path.
|
||||||
|
|
||||||
|
Returns (virtual_rel, real_rel, real_abs, is_virtual_root).
|
||||||
|
"""
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
if raw_path.strip("/") == "":
|
||||||
|
return PurePosixPath(), PurePosixPath(), base, True
|
||||||
|
|
||||||
|
try:
|
||||||
|
clean = sanitize(raw_path)
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(f"Invalid path: {e}") from e
|
||||||
|
|
||||||
|
if not clean:
|
||||||
|
return PurePosixPath(), PurePosixPath(), base, True
|
||||||
|
|
||||||
|
virtual_rel = PurePosixPath(clean)
|
||||||
|
roots = build_share_roots(token)
|
||||||
|
if not roots:
|
||||||
|
raise NotFound("Share token has no visible files")
|
||||||
|
|
||||||
|
root_by_alias = {r.alias: r.real_rel for r in roots}
|
||||||
|
first = virtual_rel.parts[0]
|
||||||
|
real_root = root_by_alias.get(first)
|
||||||
|
if real_root is None:
|
||||||
|
raise NotFound(f"Not found: {raw_path}")
|
||||||
|
|
||||||
|
rest = virtual_rel.parts[1:]
|
||||||
|
real_rel = real_root.joinpath(*rest) if rest else real_root
|
||||||
|
resolved = (base / real_rel).resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise BadRequest("Invalid path")
|
||||||
|
return virtual_rel, real_rel, resolved, False
|
||||||
|
|
||||||
|
|
||||||
|
def real_to_virtual_aliases(token: config.Token) -> dict[PurePosixPath, str]:
|
||||||
|
return {entry.real_rel: entry.alias for entry in build_share_roots(token)}
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_virtual_entry(path: Path, name: str, level: int) -> list[FileEntry]:
|
||||||
|
st = path.lstat()
|
||||||
|
is_dir = S_ISDIR(st.st_mode)
|
||||||
|
is_file = S_ISREG(st.st_mode)
|
||||||
|
if not is_dir and not is_file:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if is_file:
|
||||||
|
try:
|
||||||
|
allocated = watching.get_allocated_size(path, st)
|
||||||
|
except Exception:
|
||||||
|
allocated = st.st_size
|
||||||
|
return [
|
||||||
|
FileEntry(
|
||||||
|
level=level,
|
||||||
|
name=name,
|
||||||
|
key=fuid(st),
|
||||||
|
mtime=int(st.st_mtime),
|
||||||
|
size=st.st_size,
|
||||||
|
allocated=allocated,
|
||||||
|
isfile=1,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
children: list[tuple[int, str, object]] = []
|
||||||
|
for child in path.iterdir():
|
||||||
|
if child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cst = child.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
c_is_file = S_ISREG(cst.st_mode)
|
||||||
|
c_is_dir = S_ISDIR(cst.st_mode)
|
||||||
|
if not c_is_file and not c_is_dir:
|
||||||
|
continue
|
||||||
|
children.append((int(c_is_file), child.name, cst))
|
||||||
|
|
||||||
|
entries: list[FileEntry] = []
|
||||||
|
agg_mtime = int(st.st_mtime)
|
||||||
|
agg_size = 0
|
||||||
|
agg_alloc = 0
|
||||||
|
|
||||||
|
for _, child_name, _ in humansorted(children):
|
||||||
|
child_path = path / child_name
|
||||||
|
child_entries = _walk_virtual_entry(child_path, child_name, level + 1)
|
||||||
|
if not child_entries:
|
||||||
|
continue
|
||||||
|
head = child_entries[0]
|
||||||
|
agg_mtime = max(agg_mtime, head.mtime)
|
||||||
|
agg_size += head.size
|
||||||
|
agg_alloc += head.allocated
|
||||||
|
entries.extend(child_entries)
|
||||||
|
|
||||||
|
head = FileEntry(
|
||||||
|
level=level,
|
||||||
|
name=name,
|
||||||
|
key=fuid(st),
|
||||||
|
mtime=agg_mtime,
|
||||||
|
size=agg_size,
|
||||||
|
allocated=agg_alloc,
|
||||||
|
isfile=0,
|
||||||
|
)
|
||||||
|
return [head, *entries]
|
||||||
|
|
||||||
|
|
||||||
|
def build_virtual_root(token: config.Token) -> list[FileEntry]:
|
||||||
|
roots = build_share_roots(token)
|
||||||
|
now = int(time())
|
||||||
|
root_key = config.derived_secret("share-root", token.key or "", token.created).hex()
|
||||||
|
|
||||||
|
entries: list[FileEntry] = []
|
||||||
|
total_size = 0
|
||||||
|
total_alloc = 0
|
||||||
|
root_mtime = 0
|
||||||
|
|
||||||
|
base = config.config.path.resolve()
|
||||||
|
for entry in roots:
|
||||||
|
real_abs = (base / entry.real_rel).resolve()
|
||||||
|
if not real_abs.is_relative_to(base) or not real_abs.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
subtree = _walk_virtual_entry(real_abs, entry.alias, 1)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not subtree:
|
||||||
|
continue
|
||||||
|
head = subtree[0]
|
||||||
|
total_size += head.size
|
||||||
|
total_alloc += head.allocated
|
||||||
|
root_mtime = max(root_mtime, head.mtime)
|
||||||
|
entries.extend(subtree)
|
||||||
|
|
||||||
|
root = FileEntry(
|
||||||
|
level=0,
|
||||||
|
name="",
|
||||||
|
key=root_key,
|
||||||
|
mtime=root_mtime or now,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_alloc,
|
||||||
|
isfile=0,
|
||||||
|
)
|
||||||
|
return [root, *entries]
|
||||||
|
|
||||||
|
|
||||||
|
def key_paths_for_token(
|
||||||
|
token: config.Token, wanted: set[str]
|
||||||
|
) -> dict[str, PurePosixPath]:
|
||||||
|
ret: dict[str, PurePosixPath] = {}
|
||||||
|
loc = PurePosixPath()
|
||||||
|
root = build_virtual_root(token)
|
||||||
|
for f in root:
|
||||||
|
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||||
|
if f.key in wanted and f.key not in ret:
|
||||||
|
ret[f.key] = loc
|
||||||
|
if len(ret) == len(wanted):
|
||||||
|
break
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_virtual_rel_to_real(token: config.Token, rel: PurePosixPath) -> Path:
|
||||||
|
_vrel, _rrel, real_abs, is_root = resolve_virtual_path(token, rel.as_posix())
|
||||||
|
if is_root:
|
||||||
|
raise BadRequest("Virtual root is not a writable filesystem path")
|
||||||
|
return real_abs
|
||||||
+75
-17
@@ -107,10 +107,11 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
|||||||
request.ctx.sso_user = data
|
request.ctx.sso_user = data
|
||||||
if "set-cookie" in response.headers:
|
if "set-cookie" in response.headers:
|
||||||
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
||||||
return data
|
|
||||||
except Exception:
|
except Exception:
|
||||||
request.ctx.sso_user = {}
|
request.ctx.sso_user = {}
|
||||||
return {}
|
return {}
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
error_data = response.json()
|
error_data = response.json()
|
||||||
@@ -126,22 +127,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
|||||||
context=error_data,
|
context=error_data,
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
elif response.status_code == 403:
|
if response.status_code == 403:
|
||||||
raise Forbidden(
|
raise Forbidden(
|
||||||
error_data.get("detail", "Access denied"),
|
error_data.get("detail", "Access denied"),
|
||||||
context=error_data,
|
context=error_data,
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
else:
|
detail = error_data.get("detail", "")
|
||||||
detail = error_data.get("detail", "")
|
logger.warning(
|
||||||
logger.warning(
|
f"SSO validation {url} returned {response.status_code}: {detail}"
|
||||||
f"SSO validation {url} returned {response.status_code}: {detail}"
|
)
|
||||||
)
|
raise Forbidden(
|
||||||
raise Forbidden(
|
detail or "Authentication error",
|
||||||
detail or "Authentication error",
|
context=error_data,
|
||||||
context=error_data,
|
quiet=True,
|
||||||
quiet=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"SSO validation {url} network error: {e}")
|
logger.error(f"SSO validation {url} network error: {e}")
|
||||||
@@ -149,8 +149,64 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
|||||||
"Authentication service unavailable",
|
"Authentication service unavailable",
|
||||||
status_code=502,
|
status_code=502,
|
||||||
quiet=True,
|
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):
|
async def proxy_auth_request(request):
|
||||||
"""Proxy a request to the auth backend.
|
"""Proxy a request to the auth backend.
|
||||||
@@ -202,7 +258,7 @@ async def proxy_auth_request(request):
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
url=url,
|
url=url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
content=request.body if request.body else None,
|
content=request.body or None,
|
||||||
) as response:
|
) as response:
|
||||||
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||||
|
|
||||||
@@ -267,15 +323,15 @@ async def proxy_auth_websocket(request, ws):
|
|||||||
try:
|
try:
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
await backend_ws.send(message)
|
await backend_ws.send(message)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug("WebSocket forward_to_backend ended: %s", e)
|
||||||
|
|
||||||
async def forward_to_client():
|
async def forward_to_client():
|
||||||
try:
|
try:
|
||||||
async for message in backend_ws:
|
async for message in backend_ws:
|
||||||
await ws.send(message)
|
await ws.send(message)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug("WebSocket forward_to_client ended: %s", e)
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
forward_to_backend(),
|
forward_to_backend(),
|
||||||
@@ -293,6 +349,7 @@ bp = Blueprint("sso", url_prefix="/auth")
|
|||||||
@bp.websocket("/ws/<path:path>")
|
@bp.websocket("/ws/<path:path>")
|
||||||
async def auth_websocket_proxy(request, ws, path=""):
|
async def auth_websocket_proxy(request, ws, path=""):
|
||||||
"""Proxy WebSocket connections to the auth backend."""
|
"""Proxy WebSocket connections to the auth backend."""
|
||||||
|
_ = path
|
||||||
await proxy_auth_websocket(request, ws)
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
@@ -307,6 +364,7 @@ async def auth_websocket_proxy_root(request, ws):
|
|||||||
)
|
)
|
||||||
async def auth_proxy(request, path=""):
|
async def auth_proxy(request, path=""):
|
||||||
"""Proxy all auth requests to the auth backend."""
|
"""Proxy all auth requests to the auth backend."""
|
||||||
|
_ = path
|
||||||
return await proxy_auth_request(request)
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ def jres(data, **kwargs):
|
|||||||
|
|
||||||
async def handle_sanic_exception(request, e):
|
async def handle_sanic_exception(request, e):
|
||||||
context, code = {}, 500
|
context, code = {}, 500
|
||||||
|
headers = None
|
||||||
message = str(e)
|
message = str(e)
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
context = e.context or {}
|
context = e.context or {}
|
||||||
code = e.status_code
|
code = e.status_code
|
||||||
if not message or not request.app.debug and code == 500:
|
headers = getattr(e, "headers", None)
|
||||||
|
if not message or (not request.app.debug and code == 500):
|
||||||
message = "Internal Server Error"
|
message = "Internal Server Error"
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
if code == 500:
|
if code == 500:
|
||||||
@@ -41,6 +43,7 @@ async def handle_sanic_exception(request, e):
|
|||||||
return jres(
|
return jres(
|
||||||
response_data,
|
response_data,
|
||||||
status=code,
|
status=code,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
if "redirect" in context:
|
if "redirect" in context:
|
||||||
@@ -57,9 +60,10 @@ def websocket_wrapper(handler):
|
|||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
username = getattr(request.ctx, "username", None)
|
username = getattr(request.ctx, "username", None)
|
||||||
extra = username if username else None
|
extra = username or None
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
ws_id = log_ws_open(request, extra=extra)
|
ws_id = log_ws_open(request, extra=extra)
|
||||||
|
close_extra = None
|
||||||
try:
|
try:
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
@@ -72,6 +76,7 @@ def websocket_wrapper(handler):
|
|||||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||||
if not getattr(e, "quiet", False) or code == 500:
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
logger.exception(f"{code} {e!r}")
|
logger.exception(f"{code} {e!r}")
|
||||||
|
close_extra = f"{code} {message}"
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
duration = time.perf_counter() - start
|
duration = time.perf_counter() - start
|
||||||
@@ -86,6 +91,6 @@ def websocket_wrapper(handler):
|
|||||||
close_code = p.close_code
|
close_code = p.close_code
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
log_ws_close(ws_id, close_code, duration)
|
log_ws_close(ws_id, close_code, duration, extra=close_extra)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AsyncLink:
|
|||||||
@property
|
@property
|
||||||
def to_sync(self):
|
def to_sync(self):
|
||||||
"""Yield SyncRequests from async caller when called from worker thread."""
|
"""Yield SyncRequests from async caller when called from worker thread."""
|
||||||
while (req := self._await(self._get())) is not None:
|
while (req := self.await_sync(self._get())) is not None:
|
||||||
yield SyncRequest(self, req)
|
yield SyncRequest(self, req)
|
||||||
|
|
||||||
async def _get(self):
|
async def _get(self):
|
||||||
@@ -33,14 +33,14 @@ class AsyncLink:
|
|||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _await(self, coro):
|
def await_sync(self, coro):
|
||||||
"""Run coroutine in main thread and return result; called from worker."""
|
"""Run coroutine in main thread and return result; called from worker."""
|
||||||
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
|
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Stop worker and clean up."""
|
"""Stop worker and clean up."""
|
||||||
while not self.queue.empty():
|
while not self.queue.empty():
|
||||||
command, future = self.queue.get_nowait()
|
_command, future = self.queue.get_nowait()
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(Exception("AsyncLink stopped"))
|
future.set_exception(Exception("AsyncLink stopped"))
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
@@ -87,9 +87,9 @@ class SyncRequest:
|
|||||||
def set_result(self, value):
|
def set_result(self, value):
|
||||||
"""Set result value; mark as done."""
|
"""Set result value; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, value))
|
self.alink.await_sync(set_result(self.future, value))
|
||||||
|
|
||||||
def set_exception(self, exc):
|
def set_exception(self, exc):
|
||||||
"""Set exception; mark as done."""
|
"""Set exception; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, exception=exc))
|
self.alink.await_sync(set_result(self.future, exception=exc))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class LRUCache:
|
class LRUCache:
|
||||||
@@ -7,22 +7,22 @@ class LRUCache:
|
|||||||
LRUCache is a least-recently-used (LRU) cache with expiry time.
|
LRUCache is a least-recently-used (LRU) cache with expiry time.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
open (callable): Function to open a new handle.
|
opener (callable): Function to open a new handle.
|
||||||
capacity (int): Max number of items in the cache.
|
capacity (int): Max number of items in the cache.
|
||||||
maxage (float): Max age for items in cache in seconds.
|
maxage (float): Max age for items in cache in seconds.
|
||||||
cache (list): Internal list storing the cache items.
|
cache (list): Internal list storing the cache items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, open: Callable, *, capacity: int, maxage: float):
|
def __init__(self, opener: Callable, *, capacity: int, maxage: float):
|
||||||
"""
|
"""
|
||||||
Initialize LRUCache.
|
Initialize LRUCache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
open (callable): Function to open a new handle.
|
opener (callable): Function to open a new handle.
|
||||||
capacity (int): Maximum capacity of the cache.
|
capacity (int): Maximum capacity of the cache.
|
||||||
maxage (float): Max age for items in cache in seconds.
|
maxage (float): Max age for items in cache in seconds.
|
||||||
"""
|
"""
|
||||||
self.open = open
|
self.opener = opener
|
||||||
self.capacity = capacity
|
self.capacity = capacity
|
||||||
self.maxage = maxage
|
self.maxage = maxage
|
||||||
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
|
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
|
||||||
@@ -47,7 +47,7 @@ class LRUCache:
|
|||||||
self.cache.pop(i)
|
self.cache.pop(i)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
f = self.open(key)
|
f = self.opener(key)
|
||||||
# Add/restore to end of cache
|
# Add/restore to end of cache
|
||||||
self.cache.insert(0, (key, f, monotonic()))
|
self.cache.insert(0, (key, f, monotonic()))
|
||||||
self.expire_items()
|
self.expire_items()
|
||||||
@@ -58,7 +58,9 @@ class LRUCache:
|
|||||||
Expire items that are either too old or exceed cache capacity.
|
Expire items that are either too old or exceed cache capacity.
|
||||||
"""
|
"""
|
||||||
ts = monotonic() - self.maxage
|
ts = monotonic() - self.maxage
|
||||||
while len(self.cache) > self.capacity or self.cache and self.cache[-1][2] < ts:
|
while len(self.cache) > self.capacity or (
|
||||||
|
self.cache and self.cache[-1][2] < ts
|
||||||
|
):
|
||||||
self.cache.pop()[1].close()
|
self.cache.pop()[1].close()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
+1028
-53
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
from typing import Protocol
|
||||||
|
from unicodedata import normalize
|
||||||
|
|
||||||
|
import argon2
|
||||||
|
|
||||||
|
_argon = argon2.PasswordHasher()
|
||||||
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||||
|
|
||||||
|
|
||||||
|
class SupportsHash(Protocol):
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_secret(value: str) -> bytes:
|
||||||
|
return normalize("NFC", value).strip().encode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hash(user_hash: str, *, username: str, password: str) -> bool:
|
||||||
|
"""Verify password hash and return whether the stored hash should be upgraded."""
|
||||||
|
if not user_hash:
|
||||||
|
raise ValueError("Account disabled")
|
||||||
|
|
||||||
|
normalized_username = normalize_secret(username)
|
||||||
|
normalized_password = normalize_secret(password)
|
||||||
|
|
||||||
|
if (match := _droppyhash.match(user_hash)) is not None:
|
||||||
|
expected_hash, salt = match.groups()
|
||||||
|
computed_hash = hmac.digest(
|
||||||
|
normalized_password + salt.encode() + normalized_username,
|
||||||
|
b"",
|
||||||
|
"sha256",
|
||||||
|
).hex()
|
||||||
|
if not hmac.compare_digest(expected_hash, computed_hash):
|
||||||
|
raise ValueError("Invalid password")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
_argon.verify(user_hash, normalized_password)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid password") from None
|
||||||
|
return _argon.check_needs_rehash(user_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(user: SupportsHash, password: str) -> None:
|
||||||
|
user.hash = _argon.hash(normalize_secret(password))
|
||||||
+25
-18
@@ -9,6 +9,7 @@ from os import stat_result
|
|||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from stat import S_ISDIR, S_ISREG
|
from stat import S_ISDIR, S_ISREG
|
||||||
|
|
||||||
|
import inotify.adapters
|
||||||
import msgspec
|
import msgspec
|
||||||
from natsort import humansorted, natsort_keygen, ns
|
from natsort import humansorted, natsort_keygen, ns
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
@@ -30,6 +31,7 @@ if sys.platform == "win32":
|
|||||||
|
|
||||||
def get_allocated_size(path: Path, st: stat_result) -> int:
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
||||||
|
_ = st
|
||||||
high = wintypes.DWORD()
|
high = wintypes.DWORD()
|
||||||
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
||||||
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
||||||
@@ -40,6 +42,7 @@ else:
|
|||||||
|
|
||||||
def get_allocated_size(path: Path, st: stat_result) -> int:
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
"""Get actual disk allocation on Unix using st_blocks."""
|
"""Get actual disk allocation on Unix using st_blocks."""
|
||||||
|
_ = path
|
||||||
# st_blocks is in 512-byte units
|
# st_blocks is in 512-byte units
|
||||||
return st.st_blocks * 512
|
return st.st_blocks * 512
|
||||||
|
|
||||||
@@ -48,6 +51,10 @@ pubsub = {}
|
|||||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatUpdateLoopError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
@@ -141,8 +148,8 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
|
|||||||
|
|
||||||
|
|
||||||
state = State()
|
state = State()
|
||||||
rootpath: Path = None # type: ignore
|
rootpath: Path | None = None
|
||||||
quit = threading.Event()
|
stop_event = threading.Event()
|
||||||
|
|
||||||
# Thread-safe queue for signaling path updates from websockets
|
# Thread-safe queue for signaling path updates from websockets
|
||||||
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||||
@@ -150,9 +157,8 @@ _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
|||||||
|
|
||||||
def notify_change(*paths: PurePosixPath | str):
|
def notify_change(*paths: PurePosixPath | str):
|
||||||
"""Signal that paths have changed. Called from control/upload websockets."""
|
"""Signal that paths have changed. Called from control/upload websockets."""
|
||||||
for path in paths:
|
for raw_path in paths:
|
||||||
if isinstance(path, str):
|
path = PurePosixPath(raw_path) if isinstance(raw_path, str) else raw_path
|
||||||
path = PurePosixPath(path)
|
|
||||||
# Convert absolute paths to relative (strip leading /)
|
# Convert absolute paths to relative (strip leading /)
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
path = (
|
path = (
|
||||||
@@ -192,10 +198,10 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
if isfile:
|
if isfile:
|
||||||
return [entry]
|
return [entry]
|
||||||
# Walk all entries of the directory
|
# Walk all entries of the directory
|
||||||
ret: list[FileEntry] = [...] # type: ignore
|
ret: list[FileEntry] = [...] # type: ignore[assignment]
|
||||||
li = []
|
li = []
|
||||||
for f in path.iterdir():
|
for f in path.iterdir():
|
||||||
if quit.is_set():
|
if stop_event.is_set():
|
||||||
raise SystemExit("quit")
|
raise SystemExit("quit")
|
||||||
if f.name.startswith("."):
|
if f.name.startswith("."):
|
||||||
continue # No dotfiles
|
continue # No dotfiles
|
||||||
@@ -245,6 +251,7 @@ def update_root(loop):
|
|||||||
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||||
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||||
new = walk(relpath)
|
new = walk(relpath)
|
||||||
|
_ = loop
|
||||||
obegin, old = treeget(rootmod, relpath)
|
obegin, old = treeget(rootmod, relpath)
|
||||||
|
|
||||||
if old == new:
|
if old == new:
|
||||||
@@ -301,7 +308,7 @@ def format_update(old, new):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
|
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
|
||||||
)
|
)
|
||||||
raise Exception(
|
raise FormatUpdateLoopError(
|
||||||
f"format_update infinite loop detected at iteration {iteration_count}"
|
f"format_update infinite loop detected at iteration {iteration_count}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -508,7 +515,7 @@ class PathIndex:
|
|||||||
|
|
||||||
if lo < len(children):
|
if lo < len(children):
|
||||||
return children[lo]
|
return children[lo]
|
||||||
elif children:
|
if children:
|
||||||
# Insert after last child's subtree
|
# Insert after last child's subtree
|
||||||
last_idx = children[-1]
|
last_idx = children[-1]
|
||||||
last_entry = self.root[last_idx]
|
last_entry = self.root[last_idx]
|
||||||
@@ -642,8 +649,6 @@ def watcher(loop):
|
|||||||
modified_flags = frozenset()
|
modified_flags = frozenset()
|
||||||
|
|
||||||
if use_inotify:
|
if use_inotify:
|
||||||
import inotify.adapters
|
|
||||||
|
|
||||||
modified_flags = frozenset(
|
modified_flags = frozenset(
|
||||||
(
|
(
|
||||||
"IN_CREATE",
|
"IN_CREATE",
|
||||||
@@ -656,10 +661,8 @@ def watcher(loop):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
while not quit.is_set():
|
while not stop_event.is_set():
|
||||||
if use_inotify:
|
if use_inotify:
|
||||||
import inotify.adapters
|
|
||||||
|
|
||||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||||
|
|
||||||
# Initialize the tree from filesystem
|
# Initialize the tree from filesystem
|
||||||
@@ -674,7 +677,11 @@ def watcher(loop):
|
|||||||
first_event_time: float | None = None
|
first_event_time: float | None = None
|
||||||
last_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."""
|
"""Add path to dirty set. Returns True if added, False if redundant."""
|
||||||
nonlocal first_event_time, last_event_time
|
nonlocal first_event_time, last_event_time
|
||||||
# Check if already covered by an existing dirty path
|
# Check if already covered by an existing dirty path
|
||||||
@@ -708,7 +715,7 @@ def watcher(loop):
|
|||||||
last_event_time = now
|
last_event_time = now
|
||||||
return True
|
return True
|
||||||
|
|
||||||
while not quit.is_set():
|
while not stop_event.is_set():
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
# Full refresh every 300s
|
# Full refresh every 300s
|
||||||
@@ -779,7 +786,7 @@ def watcher(loop):
|
|||||||
# Collect inotify events if available (short timeout for responsiveness)
|
# Collect inotify events if available (short timeout for responsiveness)
|
||||||
if inotify_tree:
|
if inotify_tree:
|
||||||
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
|
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
|
||||||
if quit.is_set():
|
if stop_event.is_set():
|
||||||
return
|
return
|
||||||
if not (modified_flags & set(event[1])):
|
if not (modified_flags & set(event[1])):
|
||||||
continue
|
continue
|
||||||
@@ -823,5 +830,5 @@ def start(app):
|
|||||||
|
|
||||||
|
|
||||||
def stop(app):
|
def stop(app):
|
||||||
quit.set()
|
stop_event.set()
|
||||||
app.ctx.watcher.join()
|
app.ctx.watcher.join()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
onlyoffice:
|
||||||
|
build:
|
||||||
|
context: ./docker/onlyoffice-converter-patch
|
||||||
|
args:
|
||||||
|
ONLYOFFICE_VERSION: "9.3.1"
|
||||||
|
container_name: onlyoffice
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
# Number of converter workers (default 8).
|
||||||
|
# Set to your CPU count or slightly below.
|
||||||
|
- WORKERS
|
||||||
|
# JWT secret shared with Cista.
|
||||||
|
# OnlyOffice reads it as JWT_SECRET; Cista reads it as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# We use ONLYOFFICE_JWT_SECRET as the canonical name so you only set one variable.
|
||||||
|
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
||||||
|
- JWT_ENABLED=true
|
||||||
|
- JWT_HEADER=Authorization
|
||||||
|
volumes:
|
||||||
|
# Persist fonts and generated caches across restarts
|
||||||
|
- onlyoffice-data:/var/www/onlyoffice/Data
|
||||||
|
- onlyoffice-lib:/var/lib/onlyoffice
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
onlyoffice-data:
|
||||||
|
onlyoffice-lib:
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Patched OnlyOffice Document Server with configurable converter worker count.
|
||||||
|
#
|
||||||
|
# The Community Edition hardcodes the document converter to 1 worker,
|
||||||
|
# which creates a severe bottleneck under concurrent load.
|
||||||
|
# This image patches the open-source license.js to spawn a configurable
|
||||||
|
# number of converter workers (default 8).
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t onlyoffice-cista docker/onlyoffice-converter-patch
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# docker run -d -p 8988:80 \
|
||||||
|
# -e WORKERS=16 \
|
||||||
|
# -e JWT_SECRET=your-strong-secret \
|
||||||
|
# --name onlyoffice onlyoffice-cista
|
||||||
|
#
|
||||||
|
# JWT:
|
||||||
|
# Set JWT_SECRET to the same value you pass to Cista as ONLYOFFICE_JWT_SECRET.
|
||||||
|
# OnlyOffice will enable token validation automatically.
|
||||||
|
#
|
||||||
|
# The ONLYOFFICE_VERSION build arg lets you target a specific release.
|
||||||
|
|
||||||
|
ARG ONLYOFFICE_VERSION=9.3.1
|
||||||
|
|
||||||
|
FROM onlyoffice/documentserver:${ONLYOFFICE_VERSION}
|
||||||
|
|
||||||
|
# Prevent interactive apt prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install Node.js, npm, and git so we can run the FileConverter from source.
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Clone the open-source server components (shallow, ~15 MB).
|
||||||
|
# The master branch is used because the Linux/web tags are not published
|
||||||
|
# in the server repo; the license.js file has been stable for years.
|
||||||
|
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
||||||
|
|
||||||
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
|
# instead of being hardcoded to 1.
|
||||||
|
RUN sed -i \
|
||||||
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/FileConverter && npm ci --no-audit --no-fund
|
||||||
|
RUN cd /opt/oo-server/DocService && npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Back up the compiled pkg binary and replace it with our wrapper.
|
||||||
|
RUN mv /var/www/onlyoffice/documentserver/server/FileConverter/converter \
|
||||||
|
/var/www/onlyoffice/documentserver/server/FileConverter/converter.orig
|
||||||
|
|
||||||
|
COPY converter-wrapper.sh /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
RUN chmod +x /var/www/onlyoffice/documentserver/server/FileConverter/converter
|
||||||
|
|
||||||
|
# Default worker count (override at runtime with -e WORKERS=16).
|
||||||
|
ENV WORKERS=8
|
||||||
|
|
||||||
|
# Use our custom entrypoint to persist the env var to a file that the
|
||||||
|
# non-root converter process (user=ds) can read.
|
||||||
|
COPY entrypoint.sh /app/ds/run-document-server-patched.sh
|
||||||
|
RUN chmod +x /app/ds/run-document-server-patched.sh
|
||||||
|
ENTRYPOINT ["/app/ds/run-document-server-patched.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper that runs the OnlyOffice FileConverter from patched Node.js source.
|
||||||
|
# Replaces the compiled pkg binary shipped with the Community Edition.
|
||||||
|
|
||||||
|
# The env var is not passed through supervisor to the 'ds' user, so we read
|
||||||
|
# it from a file written by the custom entrypoint.
|
||||||
|
if [ -z "${WORKERS}" ] && [ -r /tmp/oo-converter-workers.txt ]; then
|
||||||
|
export WORKERS=$(cat /tmp/oo-converter-workers.txt)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /opt/oo-server/FileConverter || exit 1
|
||||||
|
|
||||||
|
export NODE_ENV=production-linux
|
||||||
|
export NODE_CONFIG_DIR=/etc/onlyoffice/documentserver
|
||||||
|
export NODE_DISABLE_COLORS=1
|
||||||
|
export APPLICATION_NAME=onlyoffice
|
||||||
|
export LD_LIBRARY_PATH=/var/www/onlyoffice/documentserver/server/FileConverter/bin
|
||||||
|
|
||||||
|
exec node sources/convertermaster.js "$@"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Custom entrypoint that persists WORKERS to a file readable by
|
||||||
|
# the non-root user that supervisor uses to run the converter.
|
||||||
|
|
||||||
|
echo "${WORKERS:-8}" > /tmp/oo-converter-workers.txt
|
||||||
|
chmod 644 /tmp/oo-converter-workers.txt
|
||||||
|
|
||||||
|
exec /app/ds/run-document-server.sh "$@"
|
||||||
@@ -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",
|
"test:unit": "vitest",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
"lint": "biome lint .",
|
||||||
"format": "prettier --write src/"
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format --check .",
|
||||||
|
"check": "biome check ."
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -30,33 +32,19 @@
|
|||||||
"vue-router": "^5.0.1"
|
"vue-router": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.15.0",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@tsconfig/node18": "^18.2.6",
|
"@tsconfig/node18": "^18.2.6",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@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/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"babel-eslint": "^10.1.0",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"eslint-plugin-vue": "^10.7.0",
|
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"prettier": "^3.8.1",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.2.4"
|
"vue-tsc": "^3.2.4"
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"printWidth": 88
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-33
@@ -7,6 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
|
<UserTokensModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||||
@@ -23,20 +24,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
|
||||||
import type { ComputedRef } from 'vue'
|
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
import { useMainStore } from '@/stores/main'
|
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 Router from '@/router/index'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import { computed } from 'vue'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@@ -55,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
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(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
@@ -104,11 +109,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
const searchInput = inHeader && input
|
const searchInput = inHeader && input
|
||||||
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||||
if (event.key.startsWith("Arrow")) {
|
if (event.key.startsWith('Arrow')) {
|
||||||
const dir = event.key.slice(5).toLowerCase()
|
const dir = event.key.slice(5).toLowerCase()
|
||||||
// In search with text: left/right move cursor, up/down navigate
|
// In search with text: left/right move cursor, up/down navigate
|
||||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
return // Let browser handle cursor movement
|
return // Let browser handle cursor movement
|
||||||
}
|
}
|
||||||
arrow = dir
|
arrow = dir
|
||||||
}
|
}
|
||||||
@@ -133,8 +138,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
store.clearToast()
|
store.clearToast()
|
||||||
headerMain.value!.clearSearch(event)
|
headerMain.value!.clearSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
}
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
else if (!input && keyup && event.key === 'Backspace') {
|
|
||||||
Router.back()
|
Router.back()
|
||||||
}
|
}
|
||||||
// Select all (toggle); keydown to precede and prevent builtin
|
// Select all (toggle); keydown to precede and prevent builtin
|
||||||
@@ -149,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
else if (
|
else if (
|
||||||
!input &&
|
!input &&
|
||||||
keyup &&
|
keyup &&
|
||||||
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
|
(event.code === 'Backquote' ||
|
||||||
|
event.key === '1' ||
|
||||||
|
event.key === '2' ||
|
||||||
|
event.key === '3')
|
||||||
) {
|
) {
|
||||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||||
}
|
}
|
||||||
// Rename
|
// Rename
|
||||||
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
else if (
|
||||||
|
!input &&
|
||||||
|
c &&
|
||||||
|
keyup &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
(event.key === 'F2' || event.key === 'r')
|
||||||
|
) {
|
||||||
fileExplorer.cursorRename()
|
fileExplorer.cursorRename()
|
||||||
}
|
}
|
||||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||||
else if (!input && c && event.code === 'Space') {
|
else if (!input && c && event.code === 'Space') {
|
||||||
if (keyup && !event.altKey && !event.ctrlKey)
|
if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
|
||||||
fileExplorer.cursorSelect()
|
} else return
|
||||||
}
|
|
||||||
else return
|
|
||||||
/// We are handling this!
|
/// We are handling this!
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -172,36 +183,67 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
let f: any
|
let f: any
|
||||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||||
if (arrow && !keyup) {
|
if (arrow && !keyup) {
|
||||||
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
const focusSearch = () =>
|
||||||
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
(
|
||||||
|
document.querySelector('.headermain input[type="search"]') as HTMLElement
|
||||||
|
)?.focus()
|
||||||
|
const focusBreadcrumb = () =>
|
||||||
|
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||||
|
|
||||||
if (inBreadcrumb) {
|
if (inBreadcrumb) {
|
||||||
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||||
if (arrow === 'up') { focusSearch(); f = null }
|
if (arrow === 'up') {
|
||||||
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
focusSearch()
|
||||||
|
f = null
|
||||||
|
} else if (arrow === 'down') {
|
||||||
|
fileExplorer.focusFirst?.()
|
||||||
|
f = null
|
||||||
|
}
|
||||||
} else if (inHeader) {
|
} else if (inHeader) {
|
||||||
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
// 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 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)
|
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||||
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
if (arrow === 'left' && idx > 0) {
|
||||||
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
items[idx - 1]?.focus()
|
||||||
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
f = null
|
||||||
else if (arrow === 'down') { focusBreadcrumb(); 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 {
|
} else {
|
||||||
// File explorer: normal navigation with repeat
|
// File explorer: normal navigation with repeat
|
||||||
switch (arrow) {
|
switch (arrow) {
|
||||||
case 'up': f = () => fileExplorer.up(event); break
|
case 'up':
|
||||||
case 'down': f = () => fileExplorer.down(event); break
|
f = () => fileExplorer.up(event)
|
||||||
case 'left': f = () => fileExplorer.left(event); break
|
break
|
||||||
case 'right': f = () => fileExplorer.right(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) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
const t0 = 200,
|
||||||
|
tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
|
timer = setTimeout(() => {
|
||||||
|
timer = setInterval(f, tr)
|
||||||
|
}, t0 - tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
--header-color: #ccc;
|
--header-color: #ccc;
|
||||||
--input-background: var(--soft-color);
|
--input-background: var(--soft-color);
|
||||||
--input-color: #ddd;
|
--input-color: #ddd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (max-width: 600px) {
|
||||||
.size,
|
.size,
|
||||||
@@ -50,8 +50,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
header .headermain { order: 1; }
|
header .headermain {
|
||||||
header .breadcrumb { align-self: stretch; }
|
order: 1;
|
||||||
|
}
|
||||||
|
header .breadcrumb {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
:root {
|
:root {
|
||||||
@@ -74,7 +78,7 @@
|
|||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
}
|
}
|
||||||
.breadcrumb > a::after {
|
.breadcrumb > a::after {
|
||||||
content: '/';
|
content: "/";
|
||||||
}
|
}
|
||||||
.breadcrumb svg {
|
.breadcrumb svg {
|
||||||
fill: black !important;
|
fill: black !important;
|
||||||
@@ -101,7 +105,8 @@
|
|||||||
video::-webkit-media-controls {
|
video::-webkit-media-controls {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
tr, figure {
|
tr,
|
||||||
|
figure {
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
.selection {
|
.selection {
|
||||||
@@ -134,7 +139,7 @@ main {
|
|||||||
body {
|
body {
|
||||||
background-color: var(--primary-background);
|
background-color: var(--primary-background);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* Prevent any scrolling on body */
|
/* Prevent any scrolling on body */
|
||||||
@@ -145,7 +150,7 @@ body {
|
|||||||
}
|
}
|
||||||
tbody .size,
|
tbody .size,
|
||||||
tbody .modified {
|
tbody .modified {
|
||||||
font-family: 'Roboto Mono';
|
font-family: "Roboto Mono";
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -209,9 +214,13 @@ header nav.headermain {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.spacer { flex-grow: 1 }
|
.spacer {
|
||||||
.smallgap { flex-shrink: 1; width: 2em }
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.smallgap {
|
||||||
|
flex-shrink: 1;
|
||||||
|
width: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
|
|||||||
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
|
|||||||
|
|
||||||
// Named exports for direct imports
|
// Named exports for direct imports
|
||||||
export {
|
export {
|
||||||
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
|
AddFile,
|
||||||
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
|
AddFolder,
|
||||||
Disk, Download, Exclamation, Eye, Find, Fullscreen,
|
Arrow,
|
||||||
Github, Home, Info, Link, Logo, Loop, Menu,
|
ArrowsH,
|
||||||
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
|
ArrowsV,
|
||||||
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
|
Check,
|
||||||
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
|
Code,
|
||||||
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
|
Cog,
|
||||||
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
|
Copy,
|
||||||
Zoomin, Zoomout
|
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)
|
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||||
export const icons = {
|
export const icons = {
|
||||||
'add-file': AddFile,
|
'add-file': AddFile,
|
||||||
'add-folder': AddFolder,
|
'add-folder': AddFolder,
|
||||||
'arrow': Arrow,
|
arrow: Arrow,
|
||||||
'arrows-h': ArrowsH,
|
'arrows-h': ArrowsH,
|
||||||
'arrows-v': ArrowsV,
|
'arrows-v': ArrowsV,
|
||||||
'check': Check,
|
check: Check,
|
||||||
'code': Code,
|
code: Code,
|
||||||
'cog': Cog,
|
cog: Cog,
|
||||||
'copy': Copy,
|
copy: Copy,
|
||||||
'create-file': CreateFile,
|
'create-file': CreateFile,
|
||||||
'create-folder': CreateFolder,
|
'create-folder': CreateFolder,
|
||||||
'cross': Cross,
|
cross: Cross,
|
||||||
'disk': Disk,
|
disk: Disk,
|
||||||
'download': Download,
|
download: Download,
|
||||||
'exclamation': Exclamation,
|
exclamation: Exclamation,
|
||||||
'eye': Eye,
|
eye: Eye,
|
||||||
'find': Find,
|
find: Find,
|
||||||
'fullscreen': Fullscreen,
|
fullscreen: Fullscreen,
|
||||||
'github': Github,
|
github: Github,
|
||||||
'home': Home,
|
home: Home,
|
||||||
'info': Info,
|
info: Info,
|
||||||
'link': Link,
|
link: Link,
|
||||||
'logo': Logo,
|
logo: Logo,
|
||||||
'loop': Loop,
|
loop: Loop,
|
||||||
'menu': Menu,
|
menu: Menu,
|
||||||
'next': Next,
|
next: Next,
|
||||||
'open': Open,
|
open: Open,
|
||||||
'paste': Paste,
|
paste: Paste,
|
||||||
'pause': Pause,
|
pause: Pause,
|
||||||
'pencil': Pencil,
|
pencil: Pencil,
|
||||||
'play': Play,
|
play: Play,
|
||||||
'plus': Plus,
|
plus: Plus,
|
||||||
'previous': Previous,
|
previous: Previous,
|
||||||
'reload': Reload,
|
reload: Reload,
|
||||||
'rename': Rename,
|
rename: Rename,
|
||||||
'scissors': Scissors,
|
scissors: Scissors,
|
||||||
'shuffle': Shuffle,
|
shuffle: Shuffle,
|
||||||
'signin': Signin,
|
signin: Signin,
|
||||||
'signout': Signout,
|
signout: Signout,
|
||||||
'skip': Skip,
|
skip: Skip,
|
||||||
'spinner': Spinner,
|
spinner: Spinner,
|
||||||
'stop': Stop,
|
stop: Stop,
|
||||||
'trash': Trash,
|
trash: Trash,
|
||||||
'triangle': Triangle,
|
triangle: Triangle,
|
||||||
'unfullscreen': Unfullscreen,
|
unfullscreen: Unfullscreen,
|
||||||
'up-arrow': UpArrow,
|
'up-arrow': UpArrow,
|
||||||
'upload-cloud': UploadCloud,
|
'upload-cloud': UploadCloud,
|
||||||
'user-cog': UserCog,
|
'user-cog': UserCog,
|
||||||
'user': User,
|
user: User,
|
||||||
'volume-high': VolumeHigh,
|
'volume-high': VolumeHigh,
|
||||||
'volume-low': VolumeLow,
|
'volume-low': VolumeLow,
|
||||||
'volume-medium': VolumeMedium,
|
'volume-medium': VolumeMedium,
|
||||||
'volume-mute': VolumeMute,
|
'volume-mute': VolumeMute,
|
||||||
'window-cross': WindowCross,
|
'window-cross': WindowCross,
|
||||||
'window': Window,
|
window: Window,
|
||||||
'wordwrap': Wordwrap,
|
wordwrap: Wordwrap,
|
||||||
'zoomin': Zoomin,
|
zoomin: Zoomin,
|
||||||
'zoomout': Zoomout,
|
zoomout: Zoomout
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type IconName = keyof typeof icons
|
export type IconName = keyof typeof icons
|
||||||
|
|||||||
@@ -37,17 +37,21 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Home } from '@/assets/svg'
|
import { Home } from '@/assets/svg'
|
||||||
|
import { exists } from '@/utils/fileutil'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { exists } from '@/utils/fileutil'
|
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const home = Home
|
const home = Home
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const links = [] as Array<HTMLElement>
|
const links = [] as Array<HTMLElement>
|
||||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
const setLinkRef = (index: number, el: any) => {
|
||||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
if (el) links[index] = el
|
||||||
|
}
|
||||||
|
onBeforeUpdate(() => {
|
||||||
|
links.length = 1
|
||||||
|
}) // 1 to keep home
|
||||||
|
|
||||||
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
||||||
@@ -63,7 +67,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const longest = ref<Array<string>>([])
|
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 = () => {
|
const focusCurrent = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
|
|||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
if (isCurrent(index)) {
|
||||||
|
longest.value.splice(index)
|
||||||
|
router.push(u)
|
||||||
|
}
|
||||||
// Moving along breadcrumbs doesn't create new history
|
// Moving along breadcrumbs doesn't create new history
|
||||||
else if (long.startsWith(browser)) router.replace(u)
|
else if (long.startsWith(browser)) router.replace(u)
|
||||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||||
@@ -100,8 +108,7 @@ watchEffect(() => {
|
|||||||
if (!same) longest.value = props.path
|
if (!same) longest.value = props.path
|
||||||
else if (props.path.length > longcut.length) {
|
else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// Prune deleted folders from longest
|
// Prune deleted folders from longest
|
||||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||||
if (!exists(longest.value.slice(0, i + 1))) {
|
if (!exists(longest.value.slice(0, i + 1))) {
|
||||||
@@ -111,10 +118,11 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If needed, focus primary navigation to new location
|
// If needed, focus primary navigation to new location
|
||||||
if (props.primary) nextTick(() => {
|
if (props.primary)
|
||||||
const act = document.activeElement as HTMLElement
|
nextTick(() => {
|
||||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
const act = document.activeElement as HTMLElement
|
||||||
})
|
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
|
|||||||
const fmtSize = (bytes: number, angle: number) => {
|
const fmtSize = (bytes: number, angle: number) => {
|
||||||
const s = formatGB(bytes)
|
const s = formatGB(bytes)
|
||||||
const a = Math.abs(angle % 180)
|
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 => {
|
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||||
@@ -157,7 +157,7 @@ const freeColor = computed(() => {
|
|||||||
if (!s.disk) return '#6c6'
|
if (!s.disk) return '#6c6'
|
||||||
const freePct = s.free / s.disk
|
const freePct = s.free / s.disk
|
||||||
if (freePct > 0.25) return '#5b5'
|
if (freePct > 0.25) return '#5b5'
|
||||||
if (freePct > 0.10) return '#ff0'
|
if (freePct > 0.1) return '#ff0'
|
||||||
return '#f00'
|
return '#f00'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,23 +165,24 @@ const PIE_RADIUS = 55
|
|||||||
const LABEL_RADIUS = 62
|
const LABEL_RADIUS = 62
|
||||||
|
|
||||||
const getPoint = (angle: number, radius: number) => {
|
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) }
|
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectorInfo = computed(() => {
|
const sectorInfo = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return {
|
if (!s.disk)
|
||||||
storage: { angle: 45, pct: 0.25 },
|
return {
|
||||||
free: { angle: 180, pct: 0.5 },
|
storage: { angle: 45, pct: 0.25 },
|
||||||
other: { angle: 270, pct: 0.25 }
|
free: { angle: 180, pct: 0.5 },
|
||||||
}
|
other: { angle: 270, pct: 0.25 }
|
||||||
|
}
|
||||||
|
|
||||||
const storagePct = s.allocated / s.disk
|
const storagePct = s.allocated / s.disk
|
||||||
const freePct = s.free / s.disk
|
const freePct = s.free / s.disk
|
||||||
const otherPct = (s.used - s.allocated) / 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 freeStart = storagePct * 360
|
||||||
const freeAngle = freeStart + freePct * 180
|
const freeAngle = freeStart + freePct * 180
|
||||||
const otherStart = (storagePct + freePct) * 360
|
const otherStart = (storagePct + freePct) * 360
|
||||||
@@ -200,13 +201,19 @@ const rawAngles = computed(() => ({
|
|||||||
other: sectorInfo.value.other.angle
|
other: sectorInfo.value.other.angle
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
||||||
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
|
||||||
|
|
||||||
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||||
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
|
const storageInnerPos = computed(() =>
|
||||||
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
|
getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
|
||||||
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.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
|
// Collision avoidance for curved name labels
|
||||||
const labelLengths = computed(() => ({
|
const labelLengths = computed(() => ({
|
||||||
@@ -269,11 +276,17 @@ const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
const storageLabelPath = computed(() =>
|
||||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
|
||||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
)
|
||||||
|
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 applyAnimState = (t: number, opacity: number) => {
|
||||||
const widget = widgetRef.value
|
const widget = widgetRef.value
|
||||||
@@ -296,9 +309,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
|
|||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
const elapsed = now - startTime
|
const elapsed = now - startTime
|
||||||
const progress = Math.min(elapsed / duration, 1)
|
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
|
const t = expanding ? eased : 1 - eased
|
||||||
applyAnimState(t, t) // opacity follows position
|
applyAnimState(t, t) // opacity follows position
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
animationFrame = requestAnimationFrame(tick)
|
animationFrame = requestAnimationFrame(tick)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { apiFetch } from '@/repositories/Client'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import { zipName } from '@/utils/fileutil'
|
import { zipName } from '@/utils/fileutil'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -24,9 +24,9 @@ const status_init = {
|
|||||||
filename: '',
|
filename: '',
|
||||||
filesize: 0,
|
filesize: 0,
|
||||||
filepos: 0,
|
filepos: 0,
|
||||||
status: 'idle',
|
status: 'idle'
|
||||||
}
|
}
|
||||||
store.dprogress = {...status_init}
|
store.dprogress = { ...status_init }
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||||
// Reset
|
// Reset
|
||||||
@@ -34,8 +34,8 @@ setInterval(() => {
|
|||||||
store.dprogress.statdur = 1
|
store.dprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
// Running average by decay
|
||||||
store.dprogress.statbytes *= .9
|
store.dprogress.statbytes *= 0.9
|
||||||
store.dprogress.statdur *= .9
|
store.dprogress.statdur *= 0.9
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
@@ -44,10 +44,9 @@ const statReset = () => {
|
|||||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||||
}
|
}
|
||||||
const cancelDownloads = () => {
|
const cancelDownloads = () => {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const linkdl = (href: string) => {
|
const linkdl = (href: string) => {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = href
|
a.href = href
|
||||||
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
|
|||||||
if (e.altKey && 'showDirectoryPicker' in window) {
|
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
const handle = await window.showDirectoryPicker({
|
||||||
|
startIn: 'downloads',
|
||||||
|
mode: 'readwrite'
|
||||||
|
})
|
||||||
await filesystemdl(sel, handle)
|
await filesystemdl(sel, handle)
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
|
|||||||
// Default: ZIP download
|
// Default: ZIP download
|
||||||
zipdl(sel)
|
zipdl(sel)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,15 +11,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Cog } from '@/assets/svg'
|
import { Cog } from '@/assets/svg'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
const cog = Cog
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: string[],
|
path: string[]
|
||||||
documents: Document[],
|
documents: Document[]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
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<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@@ -87,35 +95,44 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const filesUrl = (path: string) =>
|
||||||
|
'/files/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const parseErrorMessage = async (res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||||
|
} catch {
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const rename = (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const msg = JSON.parse(ev.data)
|
|
||||||
if ('error' in msg) {
|
|
||||||
console.error('Rename failed', msg.error.message, msg.error)
|
|
||||||
doc.name = oldName
|
|
||||||
} else {
|
|
||||||
console.log('Rename succeeded', msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(
|
|
||||||
JSON.stringify({
|
|
||||||
op: 'rename',
|
|
||||||
path: `${doc.loc}/${oldName}`,
|
|
||||||
to: newName
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
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({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
console.log("New folder")
|
console.log('New folder')
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
@@ -124,7 +141,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
allocated: 0,
|
allocated: 0
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -141,7 +158,9 @@ defineExpose({
|
|||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor} .name a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus()
|
if (a) a.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -159,8 +178,12 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
up(ev: KeyboardEvent) {
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
this.cursorMove(-1, ev)
|
||||||
|
},
|
||||||
|
down(ev: KeyboardEvent) {
|
||||||
|
this.cursorMove(1, ev)
|
||||||
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
// Only go back if we're in a subfolder (not at root)
|
// Only go back if we're in a subfolder (not at root)
|
||||||
if (props.path.length > 0) {
|
if (props.path.length > 0) {
|
||||||
@@ -168,7 +191,9 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor} a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
},
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
@@ -182,8 +207,9 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index = store.cursor
|
||||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
? docs.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: docs.length
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||||
@@ -201,8 +227,7 @@ defineExpose({
|
|||||||
scrolltr = tr
|
scrolltr = tr
|
||||||
if (!scrolltimer) {
|
if (!scrolltimer) {
|
||||||
scrolltimer = setTimeout(() => {
|
scrolltimer = setTimeout(() => {
|
||||||
if (scrolltr)
|
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -214,7 +239,9 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
const el = document.querySelector(
|
||||||
|
'.headermain input[type="search"]'
|
||||||
|
) as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
@@ -245,39 +272,31 @@ const updateModified = () => {
|
|||||||
nowkey.value = Math.floor(Date.now() / 1000)
|
nowkey.value = Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
updateModified()
|
||||||
|
modifiedTimer = setInterval(updateModified, 1000)
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||||
active.focus()
|
active.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
onUnmounted(() => {
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
clearInterval(modifiedTimer)
|
||||||
const control = connect(controlUrl, {
|
})
|
||||||
open() {
|
const mkdir = async (doc: Doc, name: string) => {
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
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 showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
@@ -355,12 +374,14 @@ const copyImage = async (doc: Doc) => {
|
|||||||
if (blob.type !== 'image/png') {
|
if (blob.type !== 'image/png') {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.src = URL.createObjectURL(blob)
|
img.src = URL.createObjectURL(blob)
|
||||||
await new Promise(r => img.onload = r)
|
await new Promise(r => (img.onload = r))
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = img.naturalWidth
|
canvas.width = img.naturalWidth
|
||||||
canvas.height = img.naturalHeight
|
canvas.height = img.naturalHeight
|
||||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
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)
|
URL.revokeObjectURL(img.src)
|
||||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
} else {
|
} else {
|
||||||
@@ -373,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
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
store.hideDoc(path)
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
try {
|
||||||
message(ev: MessageEvent) {
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
const res = JSON.parse(ev.data)
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
if ('error' in res) {
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
console.error('Delete failed', res.error)
|
} catch (err) {
|
||||||
store.unhideDoc(path)
|
console.error('Delete failed', err)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.unhideDoc(path)
|
||||||
} else if (res.status === 'ack') {
|
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
|
||||||
control.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
const items = [
|
const items = [
|
||||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
{ 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) })
|
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||||
items.push(
|
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 })
|
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
|
|||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
now: number
|
now: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
// Reference props.now to trigger reactivity when time updates
|
// Reference props.now to trigger reactivity when time updates
|
||||||
const modified = computed(() => {
|
const modified = computed(() => {
|
||||||
props.now // trigger reactivity
|
props.now // trigger reactivity
|
||||||
return formatUnixDate(props.doc.mtime)
|
return formatUnixDate(props.doc.mtime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const input = ref<HTMLInputElement | null>(null)
|
const input = ref<HTMLInputElement | null>(null)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
|||||||
@@ -13,20 +13,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import SparseIndicator from './SparseIndicator.vue'
|
import { computed, ref } from 'vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
return +unit ? "bytes" : unit
|
return +unit ? 'bytes' : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipText = computed(() => {
|
const tooltipText = computed(() => {
|
||||||
|
|||||||
@@ -9,13 +9,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watchEffect
|
||||||
|
} from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
@@ -23,38 +31,51 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const filesUrl = (path: string) =>
|
||||||
|
'/files/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const parseErrorMessage = async (res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||||
|
} catch {
|
||||||
|
return `${res.status} ${res.statusText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const exit = () => { editing.value = null }
|
const exit = () => {
|
||||||
const rename = (doc: Doc, newName: string) => {
|
editing.value = null
|
||||||
|
}
|
||||||
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const msg = JSON.parse(ev.data)
|
|
||||||
if ('error' in msg) {
|
|
||||||
console.error('Rename failed', msg.error.message, msg.error)
|
|
||||||
doc.name = oldName
|
|
||||||
} else {
|
|
||||||
console.log('Rename succeeded', msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(
|
|
||||||
JSON.stringify({
|
|
||||||
op: 'rename',
|
|
||||||
path: `${doc.loc}/${oldName}`,
|
|
||||||
to: newName
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
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 gallery = ref<HTMLElement>()
|
||||||
const columnCount = ref(1)
|
const columnCount = ref(1)
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
if (!gallery.value) return
|
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)
|
const columns = computed(() => columnCount.value)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -67,7 +88,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
allocated: 0,
|
allocated: 0
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -88,7 +109,9 @@ defineExpose({
|
|||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
const a = document.querySelector(
|
||||||
|
`#file-${store.cursor}`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus()
|
if (a) a.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,10 +129,18 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
|
up(ev: KeyboardEvent) {
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
|
this.cursorMove(-columns.value, ev)
|
||||||
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
},
|
||||||
right(ev: KeyboardEvent) { this.cursorMove(1, 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) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
const select = !!ev?.shiftKey
|
||||||
// Move cursor up or down (keyboard navigation)
|
// Move cursor up or down (keyboard navigation)
|
||||||
@@ -121,11 +152,10 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index =
|
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
|
||||||
// Stop navigation sideways away from the grid (only with up/down)
|
// Stop navigation sideways away from the grid (only with up/down)
|
||||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||||
// Calculate new position
|
// Calculate new position
|
||||||
let moveto
|
let moveto
|
||||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||||
@@ -150,8 +180,7 @@ defineExpose({
|
|||||||
scrolltr = tr
|
scrolltr = tr
|
||||||
if (!scrolltimer) {
|
if (!scrolltimer) {
|
||||||
scrolltimer = setTimeout(() => {
|
scrolltimer = setTimeout(() => {
|
||||||
if (scrolltr)
|
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -163,7 +192,9 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
const el = document.querySelector(
|
||||||
|
'.headermain input[type="search"]'
|
||||||
|
) as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
@@ -176,8 +207,13 @@ watchEffect(() => {
|
|||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||||
if (editing.value) store.cursor = editing.value.key
|
if (editing.value) store.cursor = editing.value.key
|
||||||
if (store.cursor) {
|
if (store.cursor) {
|
||||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
const a = document.querySelector(
|
||||||
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
|
`#file-${store.cursor}`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
|
if (a) {
|
||||||
|
a.focus()
|
||||||
|
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -202,31 +238,20 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
const mkdir = async (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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
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 showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
@@ -294,12 +319,14 @@ const copyImage = async (doc: Doc) => {
|
|||||||
if (blob.type !== 'image/png') {
|
if (blob.type !== 'image/png') {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.src = URL.createObjectURL(blob)
|
img.src = URL.createObjectURL(blob)
|
||||||
await new Promise(r => img.onload = r)
|
await new Promise(r => (img.onload = r))
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = img.naturalWidth
|
canvas.width = img.naturalWidth
|
||||||
canvas.height = img.naturalHeight
|
canvas.height = img.naturalHeight
|
||||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
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)
|
URL.revokeObjectURL(img.src)
|
||||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
} else {
|
} else {
|
||||||
@@ -312,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
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
store.hideDoc(path)
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
try {
|
||||||
message(ev: MessageEvent) {
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
const res = JSON.parse(ev.data)
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
if ('error' in res) {
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
console.error('Delete failed', res.error)
|
} catch (err) {
|
||||||
store.unhideDoc(path)
|
console.error('Delete failed', err)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.unhideDoc(path)
|
||||||
} else if (res.status === 'ack') {
|
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
|
||||||
control.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
const items = [
|
const items = [
|
||||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
{ 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) })
|
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||||
items.push(
|
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 })
|
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,24 +31,24 @@
|
|||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { Doc } from '@/repositories/Document'
|
|
||||||
import { formatSize } from '@/utils'
|
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
import SparseIndicator from './SparseIndicator.vue'
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (name: string) => void;
|
rename: (name: string) => void
|
||||||
exit: () => void;
|
exit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc,
|
doc: Doc
|
||||||
editing?: EditingProp,
|
editing?: EditingProp
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
/>
|
/>
|
||||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
</div>
|
</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>
|
<div class="spacer smallgap"></div>
|
||||||
<DiskSpace v-if="store.space.disk" />
|
<DiskSpace v-if="store.space.disk" />
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
@@ -26,24 +32,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
|
import router from '@/router'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref } from 'vue'
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import { showAuthIframe } from 'paskia'
|
import { showAuthIframe } from 'paskia'
|
||||||
import { resumeWatching } from '@/repositories/WS'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import router from '@/router';
|
|
||||||
import DiskSpace from './DiskSpace.vue'
|
import DiskSpace from './DiskSpace.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const ssoStore = useSsoAuthStore()
|
const ssoStore = useSsoAuthStore()
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
|
const textInputFocused = ref(false)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: 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 clearSearch = (ev: Event) => {
|
||||||
const input = search.value
|
const input = search.value
|
||||||
if (input) {
|
if (input) {
|
||||||
@@ -78,7 +96,7 @@ const updateSearch = (ev: Event) => {
|
|||||||
pendingRouteUpdate = null
|
pendingRouteUpdate = null
|
||||||
let p = loc
|
let p = loc
|
||||||
p = p ? `/${p}` : ''
|
p = p ? `/${p}` : ''
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
const url = q ? `${p}//${q}` : p || '/'
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Use replace to avoid building up history for each keystroke
|
// Use replace to avoid building up history for each keystroke
|
||||||
router.replace(u)
|
router.replace(u)
|
||||||
@@ -96,41 +114,78 @@ const settingsMenu = (e: Event) => {
|
|||||||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
items.push({
|
items.push({
|
||||||
label: '👤 ' + (store.user.username || 'User Account'),
|
label: '👤 ' + (store.user.username || 'User Account'),
|
||||||
onClick: () => { window.location.href = '/auth/' }
|
onClick: () => {
|
||||||
|
window.location.href = '/auth/'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show password change for non-SSO users
|
// Only show password change for non-SSO users
|
||||||
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
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) {
|
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) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||||
} else if (store.server.public) {
|
} else if (store.server.public) {
|
||||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
items.push({ label: '🔐 Login', onClick: async () => {
|
items.push({
|
||||||
try {
|
label: '🔐 Login',
|
||||||
await showAuthIframe('/auth/restricted/#theme=light')
|
onClick: async () => {
|
||||||
resumeWatching()
|
try {
|
||||||
} catch (e) {
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
console.log('Login cancelled')
|
resumeWatching()
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Login cancelled')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}})
|
})
|
||||||
}
|
}
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
|
x: e.target.getBoundingClientRect().right,
|
||||||
items,
|
// @ts-ignore
|
||||||
|
y: e.target.getBoundingClientRect().bottom,
|
||||||
|
items
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
toggleSearchInput,
|
toggleSearchInput,
|
||||||
clearSearch,
|
clearSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateTextInputFocused()
|
||||||
|
window.addEventListener('focusin', updateTextInputFocused)
|
||||||
|
window.addEventListener('focusout', updateTextInputFocused)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('focusin', updateTextInputFocused)
|
||||||
|
window.removeEventListener('focusout', updateTextInputFocused)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -213,4 +268,34 @@ defineExpose({
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sort-hints {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sort-label {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.keycap {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
background: #ccc;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
.sort-hints {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||||
<SpinnerIcon />
|
<SpinnerIcon />
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if=previewSrc :src="previewSrc" alt="">
|
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
<img v-else-if=doc.img :src=doc.url alt="">
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
||||||
@@ -17,10 +17,11 @@
|
|||||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import type { Doc } from '@/repositories/Document'
|
|
||||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||||
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -29,7 +30,11 @@ const props = defineProps<{
|
|||||||
doc: Doc
|
doc: Doc
|
||||||
quality: string
|
quality: string
|
||||||
}>()
|
}>()
|
||||||
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '')
|
const previewSrc = computed(() =>
|
||||||
|
props.doc.previewurl
|
||||||
|
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
|
||||||
const onplay = () => {
|
const onplay = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -51,8 +56,11 @@ const applyPoster = (el: HTMLVideoElement) => {
|
|||||||
let fscurrent: HTMLVideoElement | null = null
|
let fscurrent: HTMLVideoElement | null = null
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
media.value.load() // Restore poster
|
media.value.load() // Restore poster
|
||||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
const medias = Array.from(document.querySelectorAll('video, audio')) as (
|
||||||
|
| HTMLAudioElement
|
||||||
|
| HTMLVideoElement
|
||||||
|
)[]
|
||||||
if (medias.length === 0) return
|
if (medias.length === 0) return
|
||||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||||
for (const i in medias) {
|
for (const i in medias) {
|
||||||
@@ -62,28 +70,32 @@ const next = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
|
if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
|
||||||
// Fullscreen needs to use the current video element for the next video
|
// Fullscreen needs to use the current video element for the next video
|
||||||
// because we are not allowed to fullscreen the next one.
|
// because we are not allowed to fullscreen the next one.
|
||||||
// FIXME: Write our own player to avoid this problem...
|
// FIXME: Write our own player to avoid this problem...
|
||||||
const elem = media.value as HTMLVideoElement
|
const elem = media.value as HTMLVideoElement
|
||||||
const playing = el as HTMLVideoElement
|
const playing = el as HTMLVideoElement
|
||||||
if (elem === playing) {
|
if (elem === playing) {
|
||||||
playing.play() // Only one video, just replay
|
playing.play() // Only one video, just replay
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!fscurrent) {
|
if (!fscurrent) {
|
||||||
elem.addEventListener('fullscreenchange', ev => {
|
elem.addEventListener(
|
||||||
if (!fscurrent) return
|
'fullscreenchange',
|
||||||
// Restore the original video element and continue with the one that was playing
|
ev => {
|
||||||
fscurrent.currentTime = elem.currentTime
|
if (!fscurrent) return
|
||||||
fscurrent.click()
|
// Restore the original video element and continue with the one that was playing
|
||||||
if (!elem.paused) fscurrent.play()
|
fscurrent.currentTime = elem.currentTime
|
||||||
fscurrent = null
|
fscurrent.click()
|
||||||
elem.src = props.doc.url
|
if (!elem.paused) fscurrent.play()
|
||||||
applyPoster(elem)
|
fscurrent = null
|
||||||
onpaused()
|
elem.src = props.doc.url
|
||||||
}, {once: true})
|
applyPoster(elem)
|
||||||
|
onpaused()
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fscurrent = playing
|
fscurrent = playing
|
||||||
elem.src = playing.src
|
elem.src = playing.src
|
||||||
@@ -99,7 +111,10 @@ defineExpose({
|
|||||||
if (!media.value) return false
|
if (!media.value) return false
|
||||||
if (media.value.paused) {
|
if (media.value.paused) {
|
||||||
media.value.play()
|
media.value.play()
|
||||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
for (const el of Array.from(document.querySelectorAll('video, audio')) as (
|
||||||
|
| HTMLAudioElement
|
||||||
|
| HTMLVideoElement
|
||||||
|
)[]) {
|
||||||
if (el === media.value) continue
|
if (el === media.value) continue
|
||||||
el.pause()
|
el.pause()
|
||||||
}
|
}
|
||||||
@@ -108,19 +123,25 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
media,
|
media
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const video = () => props.doc.video
|
||||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
const audio = () => props.doc.audio
|
||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
const archive = () => props.doc.archive
|
||||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
const docs = () => props.doc.document
|
||||||
|
// image = requires server-side preview (browsers cannot display it natively)
|
||||||
|
// img = browser-viewable image that can be used directly in an <img> tag
|
||||||
|
const image = () => props.doc.image
|
||||||
|
const print = () => props.doc.print
|
||||||
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||||
const preview = () => (
|
const preview = () => {
|
||||||
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
const store = useMainStore()
|
||||||
props.doc.size > 500000 &&
|
return (
|
||||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
!(store.server.office_previews === false && docs()) &&
|
||||||
)
|
(image() || print() || (props.doc.img && props.doc.size > 500000))
|
||||||
|
)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watchEffect, nextTick } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
import { nextTick, ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
const overlay = ref<HTMLDivElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
const dialog = ref<HTMLDivElement | null>(null)
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
@@ -27,9 +27,9 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string,
|
title: string
|
||||||
name: typeof store.dialog,
|
name: typeof store.dialog
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
|
|||||||
@@ -10,13 +10,12 @@
|
|||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
}>()
|
}>()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="select-size">{{ selectionDisplay.size }}</span>
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
<DownloadButton />
|
<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="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
@@ -29,12 +34,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { computed, ref } from 'vue'
|
import { createShareToken } from '@/repositories/User'
|
||||||
import { formatSize } from '@/utils'
|
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
import router from '@/router'
|
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 unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
@@ -49,6 +56,22 @@ const navigateTo = (path: string) => {
|
|||||||
router.push('/' + path)
|
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
|
// Truncate long names to reasonable length
|
||||||
const truncateName = (name: string, maxLen = 20): string => {
|
const truncateName = (name: string, maxLen = 20): string => {
|
||||||
if (name.length <= maxLen) return name
|
if (name.length <= maxLen) return name
|
||||||
@@ -98,7 +121,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
|
|||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
displayName = truncateName(names[0]!)
|
displayName = truncateName(names[0]!)
|
||||||
} else {
|
} else {
|
||||||
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
|
||||||
displayName = `${truncateName(folderName)} (${count})`
|
displayName = `${truncateName(folderName)} (${count})`
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -115,46 +138,74 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const op = (opName: string, dst?: string) => {
|
const op = async (opName: string, dst?: string) => {
|
||||||
const sel = store.selectedFiles
|
const sel = store.selectedFiles
|
||||||
|
const keys = sel.keys
|
||||||
const paths = sel.keys.map(key => {
|
const paths = sel.keys.map(key => {
|
||||||
const doc = sel.docs[key]!
|
const doc = sel.docs[key]!
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
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)
|
// Hide items being deleted or moved (optimistic update)
|
||||||
if (opName === 'rm' || opName === 'mv') {
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
for (const path of paths) store.hideDoc(path)
|
for (const path of paths) store.hideDoc(path)
|
||||||
}
|
}
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(ev: MessageEvent) {
|
try {
|
||||||
const res = JSON.parse(ev.data)
|
if (opName === 'rm') {
|
||||||
if ('error' in res) {
|
for (const path of paths) {
|
||||||
console.error('Control socket error', msg, res.error)
|
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||||
store.error = res.error.message
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
// Restore hidden items on error
|
}
|
||||||
if (opName === 'rm' || opName === 'mv') {
|
} else if (opName === 'mv' || opName === 'cp') {
|
||||||
for (const path of paths) store.unhideDoc(path)
|
if (keys.length === 0) throw new Error('No selected files')
|
||||||
}
|
const dstUrl = dst ? filesUrl(dst) : '/files/'
|
||||||
return
|
const query = `${opName}=${keys.join('+')}`
|
||||||
} else if (res.status === 'ack') {
|
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
|
||||||
console.log('Control ack OK', res)
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
control.close()
|
} else {
|
||||||
store.selected.clear()
|
throw new Error(`Unsupported operation: ${opName}`)
|
||||||
return
|
}
|
||||||
} else console.log('Unknown control response', msg, res)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify(msg))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { changePassword } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { changePassword } from '@/repositories/User'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { icons, type IconName } from '@/assets/svg'
|
import { type IconName, icons } from '@/assets/svg'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineEmits(['cancel'])
|
defineEmits(['cancel'])
|
||||||
|
|
||||||
@@ -38,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(() => {
|
const speed = computed(() => {
|
||||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||||
return s // "Current speed"
|
return s // "Current speed"
|
||||||
})
|
})
|
||||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
const speeddisp = computed(() =>
|
||||||
|
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Doc } from '@/repositories/Document'
|
||||||
import { collator } from '@/utils';
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { collator } from '@/utils'
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -29,6 +28,22 @@ type CloudFile = {
|
|||||||
cloudName: string
|
cloudName: string
|
||||||
cloudPos: number
|
cloudPos: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadRange = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type InflightBlock = {
|
||||||
|
name: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||||
function pasteHandler(event: ClipboardEvent) {
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
const infiles = [] as File[]
|
const infiles = [] as File[]
|
||||||
@@ -46,7 +61,9 @@ function pasteHandler(event: ClipboardEvent) {
|
|||||||
if (infiles.length || dirs.length) {
|
if (infiles.length || dirs.length) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
uploadFiles(infiles)
|
uploadFiles(infiles)
|
||||||
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
|
const base = props.path!.join('/')
|
||||||
|
for (const entry of dirs)
|
||||||
|
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||||
@@ -56,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const cloudName = `${loc}/${entry.name}`
|
const cloudName = `${loc}/${entry.name}`
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const file = await new Promise(resolve => entry.file(resolve)) as File
|
const file = (await new Promise(resolve => entry.file(resolve))) as File
|
||||||
cloudfiles.push({file, cloudName, cloudPos: 0})
|
cloudfiles.push({ file, cloudName, cloudPos: 0 })
|
||||||
} else if (entry.isDirectory) {
|
} else if (entry.isDirectory) {
|
||||||
await pasteDirectory(entry, cloudName)
|
await pasteDirectory(entry, cloudName)
|
||||||
}
|
}
|
||||||
@@ -68,7 +85,9 @@ function uploadHandler(event: Event) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const input = event.target as HTMLInputElement | null
|
const input = event.target as HTMLInputElement | null
|
||||||
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
const infiles = Array.from(
|
||||||
|
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
|
||||||
|
) as File[]
|
||||||
if (input) input.value = ''
|
if (input) input.value = ''
|
||||||
if (infiles.length) uploadFiles(infiles)
|
if (infiles.length) uploadFiles(infiles)
|
||||||
}
|
}
|
||||||
@@ -82,8 +101,8 @@ const uploadFiles = (infiles: File[]) => {
|
|||||||
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||||
files.push({
|
files.push({
|
||||||
file,
|
file,
|
||||||
cloudName: loc + '/' + relPath,
|
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||||
cloudPos: 0,
|
cloudPos: 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
uploadCloudFiles(files)
|
uploadCloudFiles(files)
|
||||||
@@ -115,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const folderPath = parts.slice(0, i + 1).join('/')
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
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)
|
added.add(folderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
const existing = byPath.get(f.cloudName)
|
const existing = byPath.get(f.cloudName)
|
||||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
if (!existing)
|
||||||
|
store.addGhost(
|
||||||
|
new Doc({
|
||||||
|
loc,
|
||||||
|
name,
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
size: f.file.size,
|
||||||
|
allocated: 0,
|
||||||
|
mtime: now,
|
||||||
|
dir: false
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
@@ -130,8 +170,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelUploads = () => {
|
const cancelUploads = () => {
|
||||||
|
uploadRunId += 1
|
||||||
upqueue = []
|
upqueue = []
|
||||||
blockQueue = []
|
blockQueue = []
|
||||||
|
inflightBlocks.clear()
|
||||||
|
uploadedBytes.clear()
|
||||||
|
store.uprogress.status = 'idle'
|
||||||
statReset()
|
statReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,56 +193,106 @@ const uprogress_init = {
|
|||||||
filename: '',
|
filename: '',
|
||||||
filesize: 0,
|
filesize: 0,
|
||||||
filepos: 0,
|
filepos: 0,
|
||||||
status: 'idle',
|
status: 'idle'
|
||||||
}
|
}
|
||||||
store.uprogress = {...uprogress_init}
|
store.uprogress = { ...uprogress_init }
|
||||||
|
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||||
|
const uploadedBytes = new Map<string, Set<number>>()
|
||||||
|
const inflightBlocks = new Map<string, InflightBlock>()
|
||||||
|
let smoothedBlockMs = 1500
|
||||||
|
let lastProgressTick = Date.now()
|
||||||
|
let lastVisualUploaded = 0
|
||||||
|
|
||||||
|
const inflightKey = (name: string, start: number) => `${name}:${start}`
|
||||||
|
|
||||||
|
const completedUploadedBytes = (name: string, size: number) => {
|
||||||
|
const uploaded = uploadedBytes.get(name)
|
||||||
|
if (!uploaded) return 0
|
||||||
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
|
let total = 0
|
||||||
|
for (let i = 0; i < size; i += blockSize) {
|
||||||
|
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulatedInflightBytes = (name: string, now: number) => {
|
||||||
|
let total = 0
|
||||||
|
for (const block of inflightBlocks.values()) {
|
||||||
|
if (block.name !== name) continue
|
||||||
|
const size = block.end - block.start
|
||||||
|
const elapsed = Math.max(0, now - block.startedAt)
|
||||||
|
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
|
||||||
|
total += size * fraction
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshProgress = (now: number) => {
|
||||||
|
const name = store.uprogress.filename
|
||||||
|
const size = store.uprogress.filesize
|
||||||
|
if (!name || !size) {
|
||||||
|
lastProgressTick = now
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = completedUploadedBytes(name, size)
|
||||||
|
const estimated = simulatedInflightBytes(name, now)
|
||||||
|
const visualUploaded = Math.min(size, Math.round(completed + estimated))
|
||||||
|
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
|
||||||
|
const dt = Math.max(1, now - lastProgressTick)
|
||||||
|
|
||||||
|
store.uprogress.filepos = visualUploaded
|
||||||
|
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
store.uprogress.statbytes += delta
|
||||||
|
store.uprogress.statdur += dt
|
||||||
|
store.uprogress.tlast = now
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisualUploaded = visualUploaded
|
||||||
|
lastProgressTick = now
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
const now = Date.now()
|
||||||
// Reset
|
const delta = refreshProgress(now)
|
||||||
|
if (delta > 0) return
|
||||||
|
if (now - store.uprogress.tlast > 3000) {
|
||||||
store.uprogress.statbytes = 0
|
store.uprogress.statbytes = 0
|
||||||
store.uprogress.statdur = 1
|
store.uprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
store.uprogress.statbytes *= 0.95
|
||||||
store.uprogress.statbytes *= .9
|
store.uprogress.statdur *= 0.95
|
||||||
store.uprogress.statdur *= .9
|
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
// Track uploaded bytes for each file to handle out-of-order uploads
|
|
||||||
const uploadedBytes = new Map<string, Set<number>>()
|
|
||||||
|
|
||||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
const statUpdate = ({ name, size, start, end }: UploadRange) => {
|
||||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
// Track which bytes have been uploaded (using start to end range)
|
// Track which bytes have been uploaded (using start to end range)
|
||||||
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||||
const uploaded = uploadedBytes.get(name)!
|
const uploaded = uploadedBytes.get(name)!
|
||||||
const blockSize = 1 << 20
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
|
|
||||||
// Mark all bytes in this block as uploaded
|
// Mark all bytes in this block as uploaded
|
||||||
for (let i = start; i < end; i += blockSize) {
|
for (let i = start; i < end; i += blockSize) {
|
||||||
uploaded.add(i)
|
uploaded.add(i)
|
||||||
}
|
}
|
||||||
|
refreshProgress(Date.now())
|
||||||
// Calculate total uploaded bytes for progress
|
|
||||||
let totalUploaded = 0
|
|
||||||
for (let i = 0; i < size; i += blockSize) {
|
|
||||||
if (uploaded.has(i)) totalUploaded += blockSize
|
|
||||||
}
|
|
||||||
|
|
||||||
store.uprogress.xfer = store.uprogress.filestart + totalUploaded
|
|
||||||
store.uprogress.filepos = totalUploaded
|
|
||||||
store.uprogress.statbytes += end - start
|
|
||||||
store.uprogress.statdur += now - store.uprogress.tlast
|
|
||||||
store.uprogress.tlast = now
|
|
||||||
|
|
||||||
// Check if file is fully uploaded by examining the block queue
|
// Check if file is fully uploaded by examining the block queue
|
||||||
const currentUpload = blockQueue[0]
|
const currentUpload = blockQueue[0]
|
||||||
if (!currentUpload) return
|
if (!currentUpload) return
|
||||||
|
|
||||||
if (currentUpload.file.cloudName === name && currentUpload.blockIndex >= currentUpload.blocks.length) {
|
if (
|
||||||
|
currentUpload.file.cloudName === name &&
|
||||||
|
currentUpload.completed >= currentUpload.blocks.length
|
||||||
|
) {
|
||||||
// All blocks for this file have been uploaded
|
// All blocks for this file have been uploaded
|
||||||
uploadedBytes.delete(name) // Clean up tracking
|
uploadedBytes.delete(name) // Clean up tracking
|
||||||
store.uprogress.filestart += size
|
store.uprogress.filestart += size
|
||||||
statNextFile()
|
statNextFile()
|
||||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||||
@@ -210,11 +304,15 @@ const statNextFile = () => {
|
|||||||
store.uprogress.filepos = 0
|
store.uprogress.filepos = 0
|
||||||
store.uprogress.filesize = f.file.size
|
store.uprogress.filesize = f.file.size
|
||||||
store.uprogress.filename = f.cloudName
|
store.uprogress.filename = f.cloudName
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = Date.now()
|
||||||
}
|
}
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
Object.assign(store.uprogress, uprogress_init)
|
Object.assign(store.uprogress, uprogress_init)
|
||||||
store.uprogress.t0 = Date.now()
|
store.uprogress.t0 = Date.now()
|
||||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = store.uprogress.t0
|
||||||
}
|
}
|
||||||
const statsAdd = (f: CloudFile[]) => {
|
const statsAdd = (f: CloudFile[]) => {
|
||||||
if (store.uprogress.files.length === 0) statReset()
|
if (store.uprogress.files.length === 0) statReset()
|
||||||
@@ -224,132 +322,204 @@ const statsAdd = (f: CloudFile[]) => {
|
|||||||
statNextFile()
|
statNextFile()
|
||||||
}
|
}
|
||||||
let upqueue = [] as CloudFile[]
|
let upqueue = [] as CloudFile[]
|
||||||
|
const MAX_PARALLEL_REQUESTS = 4
|
||||||
|
const RETRY_DELAY_MS = 400
|
||||||
|
|
||||||
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
// 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 getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
|
||||||
const BLOCK_SIZE = 1 << 20 // 1 MiB
|
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||||
const FINAL_BLOCKS_COUNT = 2
|
const FINAL_BLOCKS_COUNT = 2
|
||||||
|
|
||||||
const fileSize = file.file.size
|
const fileSize = file.file.size
|
||||||
const blocks: {start: number, end: number}[] = []
|
const blocks: { start: number; end: number }[] = []
|
||||||
|
|
||||||
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||||
// File is large enough, prioritize final blocks
|
// File is large enough, prioritize final blocks
|
||||||
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
|
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
|
||||||
|
|
||||||
// Add final blocks first
|
// Add final blocks first
|
||||||
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||||
const start = finalBlocksStart + (i * BLOCK_SIZE)
|
const start = finalBlocksStart + i * BLOCK_SIZE
|
||||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
blocks.push({start, end})
|
blocks.push({ start, end })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining blocks from beginning
|
// Add remaining blocks from beginning
|
||||||
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||||
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||||
blocks.push({start, end})
|
blocks.push({ start, end })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File is smaller, use sequential upload
|
// File is smaller, use sequential upload
|
||||||
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
blocks.push({start, end})
|
blocks.push({ start, end })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
type BlockUpload = {
|
type BlockUpload = {
|
||||||
file: CloudFile
|
file: CloudFile
|
||||||
blocks: {start: number, end: number}[]
|
blocks: { start: number; end: number }[]
|
||||||
blockIndex: number
|
nextIndex: number
|
||||||
|
completed: number
|
||||||
|
runId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockQueue = [] as BlockUpload[]
|
let blockQueue = [] as BlockUpload[]
|
||||||
|
let workerRunning = false
|
||||||
|
let uploadRunId = 0
|
||||||
|
|
||||||
const worker = async () => {
|
const enqueuePendingUploads = () => {
|
||||||
const ws = await WSCreate()
|
while (upqueue.length) {
|
||||||
while (blockQueue.length) {
|
const file = upqueue.shift()!
|
||||||
const upload = blockQueue[0]!
|
const blocks = getUploadBlocks(file)
|
||||||
const f = upload.file
|
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
|
||||||
const block = upload.blocks[upload.blockIndex]!
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const control = { name: f.cloudName, size: f.file.size, start: block.start, end: block.end }
|
const uploadUrlForFile = (cloudName: string) => {
|
||||||
const data = f.file.slice(block.start, block.end)
|
const normalized = cloudName.replace(/^\/+/, '')
|
||||||
|
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
|
||||||
|
return `/files/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
// Note: files may get modified during I/O
|
const uploadBlock = async (
|
||||||
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
|
upload: BlockUpload,
|
||||||
ws.sendMsg(control)
|
block: { start: number; end: number }
|
||||||
// @ts-ignore
|
) => {
|
||||||
await ws.sendData(data)
|
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
|
||||||
|
|
||||||
// Move to next block
|
while (true) {
|
||||||
upload.blockIndex++
|
attempt += 1
|
||||||
if (upload.blockIndex >= upload.blocks.length) {
|
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
|
||||||
// File upload complete
|
try {
|
||||||
blockQueue.shift()
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (blockQueue.length) startWorker()
|
|
||||||
store.uprogress.status = "idle"
|
|
||||||
workerRunning = false
|
|
||||||
}
|
}
|
||||||
let workerRunning: any = false
|
|
||||||
const startWorker = () => {
|
|
||||||
if (workerRunning === false) workerRunning = setTimeout(() => {
|
|
||||||
// Convert new CloudFile entries to BlockUpload entries
|
|
||||||
while (upqueue.length) {
|
|
||||||
const file = upqueue.shift()!
|
|
||||||
const blocks = getUploadBlocks(file)
|
|
||||||
blockQueue.push({ file, blocks, blockIndex: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockQueue.length) {
|
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||||
workerRunning = true
|
inflightBlocks.set(inflightKey(name, block.start), {
|
||||||
worker()
|
name,
|
||||||
} else {
|
start: block.start,
|
||||||
workerRunning = false
|
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) return
|
||||||
|
workerRunning = true
|
||||||
|
const runId = uploadRunId
|
||||||
|
setTimeout(() => {
|
||||||
|
void worker(runId)
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
|
||||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
getServerConfig,
|
||||||
|
listUsers,
|
||||||
|
updatePublic,
|
||||||
|
updateServerName,
|
||||||
|
updateUser
|
||||||
|
} from '@/repositories/User'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
username: string
|
username: string
|
||||||
@@ -92,7 +100,7 @@ const success = ref('')
|
|||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
public: false,
|
public: false,
|
||||||
name: '',
|
name: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetPassword = 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 {
|
try {
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await updateUser(user.username, { password: "" })
|
const result = await updateUser(user.username, { password: '' })
|
||||||
if (result.password) {
|
if (result.password) {
|
||||||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||||
}
|
}
|
||||||
@@ -188,14 +199,17 @@ const deleteUserAction = async (username: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
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) {
|
if (passwordMatch) {
|
||||||
await navigator.clipboard.writeText(passwordMatch[1]!)
|
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||||
if (isButtonClick) {
|
if (isButtonClick) {
|
||||||
// Show "Copied!" indication on button
|
// Show "Copied!" indication on button
|
||||||
copyButtonText.value = '✅ Copied!'
|
copyButtonText.value = '✅ Copied!'
|
||||||
// Hide password and button immediately after copying
|
// Hide password/key and button immediately after copying
|
||||||
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
const baseMessage = success.value.replace(
|
||||||
|
/(?:Password|New password|Key): .+/,
|
||||||
|
'Copied to clipboard!'
|
||||||
|
)
|
||||||
success.value = baseMessage
|
success.value = baseMessage
|
||||||
// Hide the entire message after 3 seconds
|
// Hide the entire message after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -258,18 +272,24 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Load users and config when dialog opens
|
// Load users and config when dialog opens
|
||||||
watch(() => store.dialog, (newVal) => {
|
watch(
|
||||||
if (newVal === 'usermgmt') {
|
() => store.dialog,
|
||||||
loadServerConfig()
|
newVal => {
|
||||||
if (!store.server.paskia) {
|
if (newVal === 'usermgmt') {
|
||||||
loadUsers()
|
loadServerConfig()
|
||||||
|
if (!store.server.paskia) {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
watch(() => store.server.public, (newVal) => {
|
watch(
|
||||||
serverSettings.public = newVal || false
|
() => store.server.public,
|
||||||
})
|
newVal => {
|
||||||
|
serverSettings.public = newVal || false
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=tokens title="My API Tokens">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
|
||||||
|
|
||||||
|
<!-- Creation form -->
|
||||||
|
<div v-if="mode === 'creating'" class="create-form">
|
||||||
|
<label for="token-name">Token name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="token-name"
|
||||||
|
v-model="newTokenName"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. backup-script"
|
||||||
|
@keyup.enter="submitCreate"
|
||||||
|
ref="nameInput"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
|
||||||
|
<button @click="cancelCreate" class="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation result -->
|
||||||
|
<div v-else-if="mode === 'created' && createdToken" class="created-result">
|
||||||
|
<p class="success-title">✅ Token created</p>
|
||||||
|
<p class="hint">Copy this URL — it will not be shown again.</p>
|
||||||
|
<div class="url-box">
|
||||||
|
<code class="token-url">{{ createdToken.url }}</code>
|
||||||
|
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="finishCreate" class="button primary">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token list -->
|
||||||
|
<div v-else>
|
||||||
|
<button @click="startCreate" class="button" title="Add new token">➕ Add Token</button>
|
||||||
|
<table v-if="tokens.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="token in tokens" :key="token.id">
|
||||||
|
<td>{{ token.name || 'Unnamed' }}</td>
|
||||||
|
<td>{{ formatDate(token.created) }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="empty">You have no API tokens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { createToken, deleteToken, listTokens } from '@/repositories/User'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
sso_user_id: string
|
||||||
|
name: string
|
||||||
|
created: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedToken extends Token {
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const tokens = ref<Token[]>([])
|
||||||
|
const mode = ref<'list' | 'creating' | 'created'>('list')
|
||||||
|
const newTokenName = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
const createdToken = ref<CreatedToken | null>(null)
|
||||||
|
const copyButtonText = ref('📋')
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreate = () => {
|
||||||
|
mode.value = 'list'
|
||||||
|
newTokenName.value = ''
|
||||||
|
creating.value = false
|
||||||
|
createdToken.value = null
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listTokens()
|
||||||
|
tokens.value = data.tokens
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to load tokens')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
mode.value = 'creating'
|
||||||
|
nextTick(() => nameInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureFilesBaseUrl = (url: string) => {
|
||||||
|
const trimmed = url.replace(/\/+$/, '')
|
||||||
|
if (trimmed.endsWith('/files')) return trimmed
|
||||||
|
return `${trimmed}/files`
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const result = await createToken(newTokenName.value)
|
||||||
|
await loadTokens()
|
||||||
|
if (result.url) {
|
||||||
|
createdToken.value = {
|
||||||
|
...(result as CreatedToken),
|
||||||
|
url: ensureFilesBaseUrl((result as CreatedToken).url)
|
||||||
|
}
|
||||||
|
mode.value = 'created'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to create token')
|
||||||
|
mode.value = 'list'
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!createdToken.value) return
|
||||||
|
await navigator.clipboard.writeText(createdToken.value.url)
|
||||||
|
copyButtonText.value = '✅ Copied!'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTokenAction = async (tokenId: string) => {
|
||||||
|
if (!confirm('Revoke this token? It will no longer work.')) return
|
||||||
|
try {
|
||||||
|
await deleteToken(tokenId)
|
||||||
|
await loadTokens()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to revoke token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (ts: number) => {
|
||||||
|
if (!ts) return '—'
|
||||||
|
return new Date(ts * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tokens when dialog opens
|
||||||
|
watch(
|
||||||
|
() => store.dialog,
|
||||||
|
newVal => {
|
||||||
|
if (newVal === 'tokens') {
|
||||||
|
resetCreate()
|
||||||
|
loadTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.create-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.create-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.create-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.create-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.created-result {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
color: #080;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.url-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.token-url {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.dialog-buttons {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
|
||||||
|
|
||||||
// Type for API error responses
|
// Type for API error responses
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatSize, formatUnixDate } from "@/utils"
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { FILE_TYPES, formatSize, formatUnixDate } from '@/utils'
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -11,72 +12,105 @@ export type DocProps = {
|
|||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
expires?: number // Unix timestamp for ghost expiry
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
public loc: string = ""
|
public loc: string = ''
|
||||||
public key: FUID = ""
|
public key: FUID = ''
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
public allocated: number = 0
|
public allocated: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
public ghost: 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 */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ''
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
Object.assign(this, rest)
|
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) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string {
|
||||||
|
return formatSize(this.size)
|
||||||
|
}
|
||||||
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||||
get sparseIndicator(): string {
|
get sparseIndicator(): string {
|
||||||
if (this.dir || this.size <= this.allocated) return ''
|
if (this.dir || this.size <= this.allocated) return ''
|
||||||
if (this.allocated === 0) return '⭕' // exactly zero
|
if (this.allocated === 0) return '⭕' // exactly zero
|
||||||
const ratio = this.allocated / this.size
|
const ratio = this.allocated / this.size
|
||||||
// Round to nearest 25%: ◔◑◕⬤
|
// Round to nearest 25%: ◔◑◕⬤
|
||||||
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||||
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||||
|
}
|
||||||
|
get modified(): string {
|
||||||
|
return formatUnixDate(this.mtime)
|
||||||
}
|
}
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
|
||||||
get url(): string {
|
get url(): string {
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
return this.dir
|
||||||
|
? '/#/' + `${p}/`.replaceAll('#', '%23')
|
||||||
|
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
}
|
}
|
||||||
get urlrouter(): string {
|
get urlrouter(): string {
|
||||||
return this.url.replace(/^\/#/, '')
|
return this.url.replace(/^\/#/, '')
|
||||||
}
|
}
|
||||||
get img(): boolean {
|
get img(): boolean {
|
||||||
// Folders cannot be images
|
return (
|
||||||
if (this.dir) return false
|
!this.dir && (FILE_TYPES.imageBrowser as readonly string[]).includes(this.ext)
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
)
|
||||||
|
}
|
||||||
|
get video(): boolean {
|
||||||
|
return (FILE_TYPES.video as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get audio(): boolean {
|
||||||
|
return (FILE_TYPES.audio as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get archive(): boolean {
|
||||||
|
return (FILE_TYPES.archive as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get document(): boolean {
|
||||||
|
return (FILE_TYPES.document as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
|
get image(): boolean {
|
||||||
|
return (FILE_TYPES.image as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get print(): boolean {
|
||||||
|
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
||||||
}
|
}
|
||||||
get complete(): boolean {
|
get complete(): boolean {
|
||||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||||
}
|
}
|
||||||
get previewable(): boolean {
|
get previewable(): boolean {
|
||||||
// Folders cannot be previewable
|
|
||||||
if (this.dir) return false
|
if (this.dir) return false
|
||||||
if (this.img) return true
|
return (
|
||||||
// Not a comprehensive list, but good enough for now
|
this.img ||
|
||||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
this.video ||
|
||||||
|
this.audio ||
|
||||||
|
this.image ||
|
||||||
|
this.print ||
|
||||||
|
(this.document && useMainStore().server.office_previews !== false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
if (!this.complete || this.dir) return ''
|
return !this.complete || !this.previewable
|
||||||
return this.url.replace(/^\/files/, '/preview')
|
? ''
|
||||||
|
: this.url.replace(/^\/files/, '/preview')
|
||||||
}
|
}
|
||||||
get ext(): string {
|
get ext(): string {
|
||||||
const dotIndex = this.name.lastIndexOf('.')
|
const dotIndex = this.name.lastIndexOf('.')
|
||||||
if (dotIndex === -1 || dotIndex === this.name.length - 1) return ''
|
return dotIndex === -1 || dotIndex === this.name.length - 1
|
||||||
return this.name.slice(dotIndex + 1).toLowerCase()
|
? ''
|
||||||
|
: this.name.slice(dotIndex + 1).toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type errorEvent = {
|
export type errorEvent = {
|
||||||
@@ -90,13 +124,13 @@ export type errorEvent = {
|
|||||||
// Raw types the backend /api/watch sends us
|
// Raw types the backend /api/watch sends us
|
||||||
|
|
||||||
export type FileEntry = [
|
export type FileEntry = [
|
||||||
number, // level
|
number, // level
|
||||||
string, // name
|
string, // name
|
||||||
FUID,
|
FUID,
|
||||||
number, // mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
number, // allocated (actual disk usage)
|
number, // allocated (actual disk usage)
|
||||||
number, // isfile
|
number // isfile
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export async function logoutUser() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changePassword(username: string, passwordChange: string, password: string) {
|
export async function changePassword(
|
||||||
|
username: string,
|
||||||
|
passwordChange: string,
|
||||||
|
password: string
|
||||||
|
) {
|
||||||
const data = await Client.post(url_password, {
|
const data = await Client.post(url_password, {
|
||||||
username,
|
username,
|
||||||
passwordChange,
|
passwordChange,
|
||||||
@@ -32,7 +36,11 @@ export async function listUsers() {
|
|||||||
return data
|
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, {
|
const data = await Client.post(url_users, {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
|
|||||||
return data
|
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)
|
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -63,5 +74,33 @@ export async function updateServerName(name: string) {
|
|||||||
|
|
||||||
export async function getServerConfig() {
|
export async function getServerConfig() {
|
||||||
const data = await Client.get('/api/config')
|
const data = await Client.get('/api/config')
|
||||||
return data as { name: string, public: boolean }
|
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 { useMainStore } from '@/stores/main'
|
||||||
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
|
||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import type { FileEntry, UpdateEntry, errorEvent } from './Document'
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
|
||||||
export const uploadUrl = '/api/upload'
|
|
||||||
export const watchUrl = '/api/watch'
|
export const watchUrl = '/api/watch'
|
||||||
|
|
||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
@@ -27,18 +25,22 @@ export const loadSession = () => {
|
|||||||
console.log(`Loaded session with ${tree.length} items cached`)
|
console.log(`Loaded session with ${tree.length} items cached`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Loading session failed", error)
|
console.log('Loading session failed', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSession = () => {
|
const saveSession = () => {
|
||||||
localStorage["cista-files"] = JSON.stringify(tree)
|
localStorage['cista-files'] = JSON.stringify(tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
export const connect = (
|
||||||
|
path: string,
|
||||||
|
handlers: Partial<Record<keyof WebSocketEventMap, any>>
|
||||||
|
) => {
|
||||||
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
||||||
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
|
for (const [event, handler] of Object.entries(handlers))
|
||||||
|
webSocket.addEventListener(event, handler)
|
||||||
return webSocket
|
return webSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ async function handleWsAuthError(msg: any) {
|
|||||||
// Stop reconnection attempts while showing auth dialog
|
// Stop reconnection attempts while showing auth dialog
|
||||||
awaitingAuth = true
|
awaitingAuth = true
|
||||||
store.authInProgress = true
|
store.authInProgress = true
|
||||||
store.error = '' // Clear any connection message
|
store.error = '' // Clear any connection message
|
||||||
if (watchTimeout !== null) {
|
if (watchTimeout !== null) {
|
||||||
clearTimeout(watchTimeout)
|
clearTimeout(watchTimeout)
|
||||||
watchTimeout = null
|
watchTimeout = null
|
||||||
@@ -91,9 +93,9 @@ export const watchConnect = () => {
|
|||||||
|
|
||||||
wsWatch = connect(watchUrl, {
|
wsWatch = connect(watchUrl, {
|
||||||
message: handleWatchMessage,
|
message: handleWatchMessage,
|
||||||
close: watchReconnect,
|
close: watchReconnect
|
||||||
})
|
})
|
||||||
wsWatch.addEventListener("message", event => {
|
wsWatch.addEventListener('message', event => {
|
||||||
if (store.connected) return
|
if (store.connected) return
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
@@ -105,7 +107,7 @@ export const watchConnect = () => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ("server" in msg) {
|
if ('server' in msg) {
|
||||||
console.log('Connected to backend', msg)
|
console.log('Connected to backend', msg)
|
||||||
store.server = msg.server
|
store.server = msg.server
|
||||||
store.connected = true
|
store.connected = true
|
||||||
@@ -143,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.connected) {
|
if (store.connected) {
|
||||||
console.warn("Disconnected from server", event)
|
console.warn('Disconnected from server', event)
|
||||||
store.connected = false
|
store.connected = false
|
||||||
store.error = 'Reconnecting...'
|
store.error = 'Reconnecting...'
|
||||||
}
|
}
|
||||||
@@ -153,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
|
|||||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleWatchMessage = (event: MessageEvent) => {
|
const handleWatchMessage = (event: MessageEvent) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
switch (true) {
|
switch (true) {
|
||||||
@@ -194,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
if (action === 'k') {
|
if (action === 'k') {
|
||||||
newtree.push(...tree.slice(oidx, oidx + arg))
|
newtree.push(...tree.slice(oidx, oidx + arg))
|
||||||
oidx += arg
|
oidx += arg
|
||||||
}
|
} else if (action === 'd') oidx += arg
|
||||||
else if (action === 'd') oidx += arg
|
|
||||||
else if (action === 'i') newtree.push(...arg)
|
else if (action === 'i') newtree.push(...arg)
|
||||||
else console.log("Unknown update action", action, arg)
|
else console.log('Unknown update action', action, arg)
|
||||||
}
|
}
|
||||||
if (oidx != tree.length)
|
if (oidx != tree.length)
|
||||||
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
|
throw Error(
|
||||||
|
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
|
||||||
|
)
|
||||||
store.updateRoot(newtree)
|
store.updateRoot(newtree)
|
||||||
tree = newtree
|
tree = newtree
|
||||||
saveSession()
|
saveSession()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
|
||||||
import ExplorerView from '@/views/ExplorerView.vue'
|
import ExplorerView from '@/views/ExplorerView.vue'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
|
|||||||
+58
-42
@@ -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 { Doc } from '@/repositories/Document'
|
||||||
import { defineStore, type StateTree } from 'pinia'
|
import { resumeWatching, watchConnect } from '@/repositories/WS'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
|
||||||
import SearchWorker from '@/workers/searchWorker?worker'
|
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
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -19,8 +19,8 @@ function getSearchWorker(): Worker {
|
|||||||
if (!searchWorker) {
|
if (!searchWorker) {
|
||||||
searchWorker = new SearchWorker()
|
searchWorker = new SearchWorker()
|
||||||
// Set up message handler once
|
// Set up message handler once
|
||||||
searchWorker.onmessage = (e) => {
|
searchWorker.onmessage = e => {
|
||||||
if (!searchStore || e.data.id !== searchId) return // Stale result
|
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||||
|
|
||||||
// Convert plain data back to Doc instances
|
// Convert plain data back to Doc instances
|
||||||
const docs = e.data.docs.map((d: any) => new Doc(d))
|
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
|
// Throttle rapid intermediate updates to reduce UI flicker
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
if (!e.data.done && now - lastResultUpdate < 50) {
|
if (!e.data.done && now - lastResultUpdate < 50) {
|
||||||
return // Skip intermediate update if too recent
|
return // Skip intermediate update if too recent
|
||||||
}
|
}
|
||||||
lastResultUpdate = now
|
lastResultUpdate = now
|
||||||
|
|
||||||
@@ -73,33 +73,37 @@ export const useMainStore = defineStore('main', {
|
|||||||
searchLoading: false,
|
searchLoading: false,
|
||||||
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||||
toast: '' as string, // Temporary toast (auto-dismisses)
|
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||||
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
connected: false,
|
connected: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
server: {} as Record<string, any> & {
|
||||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
public?: boolean
|
||||||
|
paskia?: boolean
|
||||||
|
office_previews?: boolean
|
||||||
|
},
|
||||||
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
gallery: false,
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
searchHotkey: '/' // Character shown for search hotkey (Slash key)
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '' as string,
|
username: '' as string,
|
||||||
privileged: false as boolean,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false as boolean,
|
isLoggedIn: false as boolean
|
||||||
},
|
},
|
||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
used: 0,
|
used: 0,
|
||||||
storage: 0,
|
storage: 0,
|
||||||
allocated: 0,
|
allocated: 0
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
@@ -114,7 +118,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
tree.selected = Array.from(tree.selected)
|
tree.selected = Array.from(tree.selected)
|
||||||
return JSON.stringify(tree)
|
return JSON.stringify(tree)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
@@ -122,22 +126,26 @@ export const useMainStore = defineStore('main', {
|
|||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(new Doc({
|
docs.push(
|
||||||
name,
|
new Doc({
|
||||||
loc: level ? loc.join('/') : '/',
|
name,
|
||||||
key,
|
loc: level ? loc.join('/') : '/',
|
||||||
size,
|
key,
|
||||||
allocated,
|
size,
|
||||||
mtime,
|
allocated,
|
||||||
dir: !isfile,
|
mtime,
|
||||||
}))
|
dir: !isfile
|
||||||
|
})
|
||||||
|
)
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
// Store in non-reactive external storage
|
// Store in non-reactive external storage
|
||||||
setDocuments(docs)
|
setDocuments(docs)
|
||||||
// Clear ghosts that now exist in the real list
|
// Clear ghosts that now exist in the real list
|
||||||
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.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))
|
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)
|
// Clear hidden paths that no longer exist (deletion confirmed)
|
||||||
for (const path of this.hiddenPaths.keys()) {
|
for (const path of this.hiddenPaths.keys()) {
|
||||||
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
|
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
|
||||||
@@ -224,14 +232,14 @@ export const useMainStore = defineStore('main', {
|
|||||||
size: doc.size,
|
size: doc.size,
|
||||||
allocated: doc.allocated,
|
allocated: doc.allocated,
|
||||||
mtime: doc.mtime,
|
mtime: doc.mtime,
|
||||||
dir: doc.dir,
|
dir: doc.dir
|
||||||
}))
|
}))
|
||||||
worker.postMessage({ type: 'update', documents: docData })
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
},
|
},
|
||||||
search(query: string, loc: string) {
|
search(query: string, loc: string) {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
const id = ++searchId
|
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
|
// Update query immediately so watchers know we're handling this
|
||||||
this.query = query
|
this.query = query
|
||||||
@@ -264,7 +272,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
|
|
||||||
// Delay showing loading indicator to avoid flicker on fast searches
|
// Delay showing loading indicator to avoid flicker on fast searches
|
||||||
loadingTimer = setTimeout(() => {
|
loadingTimer = setTimeout(() => {
|
||||||
if (searchId === id) { // Still the current search
|
if (searchId === id) {
|
||||||
|
// Still the current search
|
||||||
this.searchLoading = true
|
this.searchLoading = true
|
||||||
}
|
}
|
||||||
loadingTimer = null
|
loadingTimer = null
|
||||||
@@ -296,7 +305,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
this.cursor = ''
|
this.cursor = ''
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Logout")
|
console.log('Logout')
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -326,25 +335,29 @@ export const useMainStore = defineStore('main', {
|
|||||||
showSortToast(order: SortOrder | '') {
|
showSortToast(order: SortOrder | '') {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
'': 'Folders first',
|
'': 'Folders first',
|
||||||
'name': 'Alphabetical order',
|
name: 'Alphabetical order',
|
||||||
'modified': 'Newest first',
|
modified: 'Newest first',
|
||||||
'size': 'Largest first',
|
size: 'Largest first'
|
||||||
}
|
}
|
||||||
this.showToast(labels[order] || order, 1200)
|
this.showToast(labels[order] || order, 1200)
|
||||||
},
|
},
|
||||||
focusBreadcrumb() {
|
focusBreadcrumb() {
|
||||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||||
},
|
},
|
||||||
cancelDownloads() {
|
cancelDownloads() {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
},
|
},
|
||||||
cancelUploads() {
|
cancelUploads() {
|
||||||
location.reload() // FIXME
|
location.reload() // FIXME
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
sortOrder(): SortOrder {
|
||||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||||
|
},
|
||||||
|
isUserLogged(): boolean {
|
||||||
|
return this.user.isLoggedIn
|
||||||
|
},
|
||||||
/** Get documents count (triggers on docVersion change) */
|
/** Get documents count (triggers on docVersion change) */
|
||||||
documentCount(): number {
|
documentCount(): number {
|
||||||
// Access docVersion to make this reactive
|
// Access docVersion to make this reactive
|
||||||
@@ -366,7 +379,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
missing: new Set(),
|
missing: new Set(),
|
||||||
docs: {},
|
docs: {},
|
||||||
keys: [],
|
keys: [],
|
||||||
recursive: [],
|
recursive: []
|
||||||
}
|
}
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (selected.has(doc.key)) {
|
if (selected.has(doc.key)) {
|
||||||
@@ -384,7 +397,10 @@ export const useMainStore = defineStore('main', {
|
|||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
ret.recursive.push([base.name, basepath, base])
|
ret.recursive.push([base.name, basepath, base])
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
if (
|
||||||
|
doc.loc === basepath ||
|
||||||
|
(doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/')
|
||||||
|
) {
|
||||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
const rel = full.slice(nremove)
|
const rel = full.slice(nremove)
|
||||||
ret.recursive.push([rel, full, doc])
|
ret.recursive.push([rel, full, doc])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { clearTree } from '@/repositories/WS'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useMainStore } from './main'
|
import { useMainStore } from './main'
|
||||||
import { clearTree } from '@/repositories/WS'
|
|
||||||
|
|
||||||
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||||
const isExternalAuth = computed(() => {
|
const isExternalAuth = computed(() => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
export const exists = (path: string[]) => {
|
export const exists = (path: string[]) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
// Access docVersion to make this reactive
|
// Access docVersion to make this reactive
|
||||||
void store.docVersion
|
void store.docVersion
|
||||||
const p = path.join('/')
|
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.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
+66
-31
@@ -26,26 +26,39 @@ export function formatUnixDate(t: number) {
|
|||||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||||
if (adiff <= 5000) return 'now'
|
if (adiff <= 5000) return 'now'
|
||||||
if (adiff <= 60000) {
|
if (adiff <= 60000) {
|
||||||
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 1000), 'second')
|
||||||
|
.replace(' ago', '')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 3600000) {
|
if (adiff <= 3600000) {
|
||||||
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 60000), 'minute')
|
||||||
|
.replace('utes', '')
|
||||||
|
.replace('ute', '')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 86400000) {
|
if (adiff <= 86400000) {
|
||||||
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 3600000), 'hour')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 604800000) {
|
if (adiff <= 604800000) {
|
||||||
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F')
|
return formatter
|
||||||
|
.format(Math.round(diff / 86400000), 'day')
|
||||||
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
let d = date.toLocaleDateString('en-ie', {
|
let d = date
|
||||||
weekday: 'short',
|
.toLocaleDateString('en-ie', {
|
||||||
year: 'numeric',
|
weekday: 'short',
|
||||||
month: 'short',
|
year: 'numeric',
|
||||||
day: 'numeric'
|
month: 'short',
|
||||||
}).replace("Sept", "Sep")
|
day: 'numeric'
|
||||||
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
})
|
||||||
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
.replace('Sept', 'Sep')
|
||||||
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
||||||
|
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
||||||
|
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,41 +69,63 @@ export function getFileExtension(filename: string) {
|
|||||||
}
|
}
|
||||||
return filename.slice(dotIndex + 1)
|
return filename.slice(dotIndex + 1)
|
||||||
}
|
}
|
||||||
interface FileTypes {
|
export const FILE_TYPES = {
|
||||||
[key: string]: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const filetypes: FileTypes = {
|
|
||||||
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
||||||
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
audio: ['mp3', 'flac', 'ogg', 'aac'],
|
||||||
pdf: ['pdf'],
|
archive: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'],
|
||||||
}
|
document: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf'],
|
||||||
|
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
|
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
|
||||||
|
print: ['epub', 'mobi', 'pdf']
|
||||||
|
} as const
|
||||||
|
|
||||||
export function getFileType(name: string): string {
|
export type FileCategory = keyof typeof FILE_TYPES
|
||||||
|
|
||||||
|
export function getFileType(name: string): FileCategory | 'unknown' {
|
||||||
const dotIndex = name.lastIndexOf('.')
|
const dotIndex = name.lastIndexOf('.')
|
||||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||||
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
for (const category of Object.keys(FILE_TYPES) as FileCategory[]) {
|
||||||
|
if ((FILE_TYPES[category] as readonly string[]).includes(ext)) {
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prebuilt for fast & consistent sorting
|
// Prebuilt for fast & consistent sorting
|
||||||
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
export const collator = new Intl.Collator('en', {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
usage: 'search'
|
||||||
|
})
|
||||||
|
|
||||||
// Preformat document names for faster search
|
// Preformat document names for faster search
|
||||||
export function haystackFormat(str: string) {
|
export function haystackFormat(str: string) {
|
||||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
const based = str
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
return '^' + based + '$'
|
return '^' + based + '$'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Preformat search string for faster search
|
// Preformat search string for faster search
|
||||||
export function needleFormat(query: string) {
|
export function needleFormat(query: string) {
|
||||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
const based = query
|
||||||
return {based, words: based.split(/\s+/)}
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
return { based, words: based.split(/\s+/) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test if haystack includes needle
|
// Test if haystack includes needle
|
||||||
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
|
export function localeIncludes(
|
||||||
const {based, words} = filter
|
haystack: string,
|
||||||
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
|
filter: { based: string; words: string[] }
|
||||||
|
) {
|
||||||
|
const { based, words } = filter
|
||||||
|
return (
|
||||||
|
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import { computed, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@@ -40,7 +40,7 @@ const folderPath = computed(() => props.path.join('/'))
|
|||||||
watch(
|
watch(
|
||||||
() => [props.query, props.path.join('/')] as const,
|
() => [props.query, props.path.join('/')] as const,
|
||||||
([query, loc]) => {
|
([query, loc]) => {
|
||||||
if (store.query === query) return // Already searching this query
|
if (store.query === query) return // Already searching this query
|
||||||
store.search(query, loc)
|
store.search(query, loc)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -55,9 +55,14 @@ const documents = computed(() => {
|
|||||||
// Access docVersion to make this reactive to document changes
|
// Access docVersion to make this reactive to document changes
|
||||||
void store.docVersion
|
void store.docVersion
|
||||||
const hidden = store.hiddenPaths
|
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)
|
// 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
|
// Merge: ghosts that don't conflict with real docs
|
||||||
const realNames = new Set(docs.map(d => d.name))
|
const realNames = new Set(docs.map(d => d.name))
|
||||||
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.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)
|
// Search results from worker (also filter hidden)
|
||||||
const hidden = store.hiddenPaths
|
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
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
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
|
// 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]) => {
|
watch(
|
||||||
// React to path change or initial document load (0 → non-zero)
|
[() => props.path.join('/'), () => store.documentCount],
|
||||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
([path, len], [oldPath, oldLen]) => {
|
||||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
// React to path change or initial document load (0 → non-zero)
|
||||||
}, { immediate: true })
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ interface ResultMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Worker state
|
// Worker state
|
||||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||||
let currentSearchId = 0
|
let currentSearchId = 0
|
||||||
|
|
||||||
// Search result cache - cleared when documents change
|
// Search result cache - cleared when documents change
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
query: string // Normalized query string
|
query: string // Normalized query string
|
||||||
results: WorkerDoc[] // Matched results (up to limit)
|
results: WorkerDoc[] // Matched results (up to limit)
|
||||||
complete: boolean // True if search scanned all documents
|
complete: boolean // True if search scanned all documents
|
||||||
}
|
}
|
||||||
const searchCache: CacheEntry[] = []
|
const searchCache: CacheEntry[] = []
|
||||||
const MAX_CACHE_SIZE = 10
|
const MAX_CACHE_SIZE = 10
|
||||||
@@ -53,11 +53,21 @@ const RESULT_LIMIT = 100
|
|||||||
// Normalize string for search (remove diacritics, lowercase)
|
// Normalize string for search (remove diacritics, lowercase)
|
||||||
// Haystack adds ^ and $ markers to allow matching start/end of name
|
// Haystack adds ^ and $ markers to allow matching start/end of name
|
||||||
function normalizeHaystack(str: string): string {
|
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 {
|
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
|
// Test if document matches search query
|
||||||
@@ -143,8 +153,12 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
|||||||
|
|
||||||
// Slow path: scan all documents
|
// Slow path: scan all documents
|
||||||
const batchSize = 500
|
const batchSize = 500
|
||||||
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
|
for (
|
||||||
if (currentSearchId !== searchId) return // Superseded
|
let i = 0;
|
||||||
|
i < recentDocuments.length && results.length < RESULT_LIMIT;
|
||||||
|
i += batchSize
|
||||||
|
) {
|
||||||
|
if (currentSearchId !== searchId) return // Superseded
|
||||||
|
|
||||||
// Process batch
|
// Process batch
|
||||||
const end = Math.min(i + batchSize, recentDocuments.length)
|
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||||
@@ -175,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Post results to main thread
|
// 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)
|
const sorted = sortResults(docs, query, loc)
|
||||||
postMessage({
|
postMessage({
|
||||||
type: 'results',
|
type: 'results',
|
||||||
@@ -188,20 +208,21 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
|
|||||||
// Sort results by relevance
|
// Sort results by relevance
|
||||||
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||||
const locsub = loc + '/'
|
const locsub = loc + '/'
|
||||||
return [...docs].sort((a, b) => (
|
return [...docs].sort(
|
||||||
// Current folder first
|
(a, b) =>
|
||||||
Number(b.loc === loc) - Number(a.loc === loc) ||
|
// Current folder first
|
||||||
// Then subfolders
|
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
// Then subfolders
|
||||||
// Then by location
|
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||||
collator.compare(a.loc, b.loc) ||
|
// Then by location
|
||||||
// Folders before files
|
collator.compare(a.loc, b.loc) ||
|
||||||
Number(b.dir) - Number(a.dir) ||
|
// Folders before files
|
||||||
// Exact name match first
|
Number(b.dir) - Number(a.dir) ||
|
||||||
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
// Exact name match first
|
||||||
// Finally by name
|
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||||
collator.compare(a.name, b.name)
|
// Finally by name
|
||||||
))
|
collator.compare(a.name, b.name)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle incoming messages
|
// Handle incoming messages
|
||||||
@@ -220,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
|||||||
await performSearch(msg.query, msg.loc, msg.id)
|
await performSearch(msg.query, msg.loc, msg.id)
|
||||||
} else {
|
} else {
|
||||||
// Empty query - no results needed
|
// 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
|
* 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
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
proxy[path] = {
|
proxy[path] = {
|
||||||
target: backendUrl,
|
target: backendUrl,
|
||||||
changeOrigin: false,
|
changeOrigin: false,
|
||||||
ws: true,
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "fastapi-vite",
|
name: 'fastapi-vite',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../cista/frontend-build",
|
outDir: '../cista/frontend-build',
|
||||||
emptyOutDir: true,
|
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 fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
|
||||||
vue(),
|
vue(),
|
||||||
svgLoader(), // import svg files
|
svgLoader(), // import svg files
|
||||||
Components(), // auto import components
|
Components() // auto import components
|
||||||
],
|
],
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
less: {
|
less: {
|
||||||
modifyVars: {},
|
modifyVars: {},
|
||||||
javascriptEnabled: true,
|
javascriptEnabled: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -35,11 +35,9 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
// Bundle all SVG icons into a single chunk
|
// Bundle all SVG icons into a single chunk
|
||||||
icons: [
|
icons: ['/src/assets/svg/index.ts']
|
||||||
'/src/assets/svg/index.ts',
|
}
|
||||||
],
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
+47
-4
@@ -77,8 +77,8 @@ docs = [
|
|||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["cista/frontend-build"]
|
artifacts = ["cista/frontend-build", "cista/docker"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/buildhook.py"
|
||||||
targets.sdist.include = [
|
targets.sdist.include = [
|
||||||
"/cista",
|
"/cista",
|
||||||
]
|
]
|
||||||
@@ -115,10 +115,52 @@ filterwarnings = [
|
|||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
extend-select = ["E402"]
|
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
|
||||||
|
"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
|
||||||
|
"PLR0911", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0912", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0913", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR0915", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"PLR2004", # we like magic numbers (don't remove this suppression)
|
||||||
|
"PLW0603", # module-level shared state exists in server runtime code
|
||||||
|
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||||
|
]
|
||||||
isort.known-first-party = ["cista"]
|
isort.known-first-party = ["cista"]
|
||||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001"]
|
||||||
per-file-ignores."scripts/*" = ["T20"]
|
per-file-ignores."scripts/*" = ["T20"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -130,6 +172,7 @@ dev = [
|
|||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"sanic-testing>=24.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[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())
|
||||||
+20
-12
@@ -16,19 +16,27 @@ Environment:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.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")))
|
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,
|
||||||
|
check_ports_free,
|
||||||
|
logger,
|
||||||
|
ready,
|
||||||
|
setup_vite,
|
||||||
|
)
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.serve import parse_listen
|
from cista.serve import parse_listen
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 8989
|
||||||
DEFAULT_BACKEND_PORT = 8999
|
DEFAULT_BACKEND_PORT = 8999
|
||||||
|
HEALTH = "/api/health?from=devserver.py"
|
||||||
|
|
||||||
|
|
||||||
def setup_sanic_backend(
|
def setup_sanic_backend(
|
||||||
@@ -40,11 +48,13 @@ def setup_sanic_backend(
|
|||||||
"""
|
"""
|
||||||
config.load_config()
|
config.load_config()
|
||||||
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
|
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)
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
host = opts.get("host", "localhost") or "localhost"
|
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
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +67,7 @@ async def run_devserver(
|
|||||||
logger.warning("Frontend source not found at %s", front)
|
logger.warning("Frontend source not found at %s", front)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
frontend_url, npm_install, vite = setup_vite(frontend or "", DEFAULT_VITE_PORT)
|
||||||
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
||||||
|
|
||||||
# Tell vite where to proxy API requests
|
# Tell vite where to proxy API requests
|
||||||
@@ -65,19 +75,17 @@ async def run_devserver(
|
|||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
install_proc = await pg.spawn(*npm_install, cwd=str(front))
|
install_proc = await pg.spawn(*npm_install, cwd=str(front))
|
||||||
await asyncio.sleep(0.2) # reduce message overlap
|
await check_ports_free(frontend_url, backend_url)
|
||||||
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
|
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
|
||||||
|
|
||||||
# Wait for both install and backend to be ready
|
# Wait for dependencies to be installed and backend to accept requests
|
||||||
async with asyncio.TaskGroup() as tg:
|
await pg.wait(install_proc, ready(backend_url, path=HEALTH))
|
||||||
tg.create_task(pg.wait(install_proc))
|
|
||||||
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
|
|
||||||
|
|
||||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
||||||
await pg.spawn(*vite, cwd=str(front))
|
await pg.spawn(*vite, cwd=str(front))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run Vite and Cista (Sanic) development servers",
|
description="Run Vite and Cista (Sanic) development servers",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -95,7 +103,7 @@ def main():
|
|||||||
help="Cista backend endpoint (default: from config, or :8999)",
|
help="Cista backend endpoint (default: from config, or :8999)",
|
||||||
)
|
)
|
||||||
args, unknown = parser.parse_known_args()
|
args, unknown = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from buildutil import build
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
def initialize(self, version, build_data):
|
"""Hatch build hook that builds Vue frontend during package build."""
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
|
||||||
|
"""Build frontend before package is built."""
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
build("frontend")
|
build("frontend")
|
||||||
@@ -7,13 +7,15 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
class _PrefixFormatter(logging.Formatter):
|
||||||
"""Formatter that adds prefix based on log level."""
|
"""Formatter that adds prefix based on log level."""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
if record.levelno >= logging.WARNING:
|
if record.levelno >= logging.WARNING:
|
||||||
return f"┃ ⚠️ {record.getMessage()}"
|
return f"⚠️ {record.getMessage()}"
|
||||||
return record.getMessage()
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
@@ -30,82 +32,119 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
Raises RuntimeError if version is too old or cannot be determined.
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run( # noqa: S603
|
||||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
[node_path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
version_str = result.stdout.strip()
|
version_str = result.stdout.strip()
|
||||||
# Parse version like "v20.10.0" or "v18.17.1"
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
match = re.match(r"v(\d+)", version_str)
|
match = re.match(r"v(\d+)", version_str)
|
||||||
if match:
|
if match:
|
||||||
major_version = int(match.group(1))
|
major_version = int(match.group(1))
|
||||||
if major_version >= 20:
|
if major_version >= MIN_NODE_VERSION:
|
||||||
return
|
return
|
||||||
raise RuntimeError(
|
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
raise RuntimeError(msg)
|
||||||
)
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
pass
|
pass
|
||||||
raise RuntimeError("Could not determine Node.js version")
|
msg = "Could not determine Node.js version"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npm_runtime(tool: str) -> bool:
|
||||||
|
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||||
|
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||||
|
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||||
|
if not js_runtime_env:
|
||||||
|
return None
|
||||||
|
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
if option != runtime_name and not runtime_name.startswith(option):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
_check_node_version(node_path)
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
|
||||||
|
"""Auto-detect JavaScript runtime from available options."""
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
tool = shutil.which(option)
|
||||||
|
if not tool:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if option == "npm" and not _validate_npm_runtime(tool):
|
||||||
|
try:
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
msg = "Node.js (v20+), Deno or Bun is required but none was found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def find_js_runtime() -> tuple[str, str]:
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
Raises JSRuntimeError if no suitable runtime is found.
|
Raises RuntimeError if no suitable runtime is found.
|
||||||
"""
|
"""
|
||||||
options = ["npm", "deno", "bun"]
|
options = ["npm", "deno", "bun"]
|
||||||
node_version_error: RuntimeError | None = None
|
|
||||||
|
|
||||||
# Check for JS_RUNTIME environment variable
|
# Check for JS_RUNTIME environment variable
|
||||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
if result := _find_runtime_from_env(options):
|
||||||
js_runtime = js_runtime_env
|
return result
|
||||||
js_path = Path(js_runtime)
|
|
||||||
runtime_name = js_path.name
|
|
||||||
# Map node to npm
|
|
||||||
if runtime_name == "node":
|
|
||||||
runtime_name = "npm"
|
|
||||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
|
||||||
for option in options:
|
|
||||||
if option == runtime_name or runtime_name.startswith(option):
|
|
||||||
tool = shutil.which(js_runtime)
|
|
||||||
if tool is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
|
||||||
)
|
|
||||||
# Check Node.js version if using npm
|
|
||||||
if option == "npm":
|
|
||||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
|
||||||
if node_path is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
|
||||||
)
|
|
||||||
_check_node_version(node_path) # Raises on failure
|
|
||||||
return tool, option
|
|
||||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
|
||||||
|
|
||||||
# Auto-detect
|
# Auto-detect
|
||||||
for option in options:
|
return _auto_detect_runtime(options)
|
||||||
if tool := shutil.which(option):
|
|
||||||
# Check Node.js version if using npm
|
|
||||||
if option == "npm":
|
|
||||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
|
||||||
if node_path is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
_check_node_version(node_path)
|
|
||||||
except RuntimeError as e:
|
|
||||||
node_version_error = e
|
|
||||||
continue # Try next runtime
|
|
||||||
return tool, option
|
|
||||||
|
|
||||||
# No runtime found - provide helpful error
|
|
||||||
if node_version_error:
|
|
||||||
raise node_version_error
|
|
||||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
|
||||||
|
|
||||||
|
|
||||||
def find_build_tool():
|
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||||
"""Find JavaScript runtime and construct install/build commands.
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
@@ -143,7 +182,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return [tool, *dev_args[name]]
|
return [tool, *dev_args[name]]
|
||||||
@@ -176,16 +215,16 @@ def build(folder: str = "frontend") -> None:
|
|||||||
install_cmd, build_cmd = find_build_tool()
|
install_cmd, build_cmd = find_build_tool()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
def run(cmd):
|
def run(cmd: list[str]) -> None:
|
||||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder)
|
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run(install_cmd)
|
run(install_cmd)
|
||||||
logger.info("")
|
logger.info("")
|
||||||
run(build_cmd)
|
run(build_cmd)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|||||||
+133
-65
@@ -1,114 +1,156 @@
|
|||||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
DEFAULT_VITE_PORT = 8989
|
if TYPE_CHECKING:
|
||||||
DEFAULT_BACKEND_PORT = 8999
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup:
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
|
"""Initialize empty process tracking."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
|
self._cmds: dict[int, str] = {} # pid -> command name
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self, *cmd: str, cwd: str | None = None
|
self,
|
||||||
|
*cmd: str,
|
||||||
|
cwd: str | None = None,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Spawn a subprocess and track it."""
|
"""Spawn a subprocess and track it."""
|
||||||
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]]))
|
cmd_name = Path(cmd[0]).stem
|
||||||
|
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
self._procs.append(proc)
|
self._procs.append(proc)
|
||||||
|
self._cmds[proc.pid] = cmd_name
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
async def wait(self, proc: asyncio.subprocess.Process) -> None:
|
async def wait(
|
||||||
"""Wait for a process to complete, raise SystemExit(1) on failure."""
|
self,
|
||||||
if await proc.wait() != 0:
|
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
||||||
logger.warning("Command failed")
|
) -> None:
|
||||||
raise SystemExit(1)
|
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||||
|
returncode = await proc.wait()
|
||||||
|
if returncode != 0:
|
||||||
|
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||||
|
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||||
|
for w in waitables
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
"""Enter the async context manager."""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, *_):
|
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
cleanup_task = asyncio.create_task(self._cleanup())
|
await self._cleanup(immediate=exc_type is not None)
|
||||||
try:
|
|
||||||
await asyncio.shield(cleanup_task)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Shield was cancelled but cleanup_task continues - wait for it
|
|
||||||
await cleanup_task
|
|
||||||
|
|
||||||
async def _cleanup(self):
|
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
if not running:
|
if not running:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Wait for any one process to exit
|
if not immediate:
|
||||||
await asyncio.wait(
|
# Wait for any one process to exit
|
||||||
[asyncio.create_task(p.wait()) for p in running],
|
with suppress(asyncio.CancelledError):
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
await asyncio.wait(
|
||||||
)
|
[asyncio.create_task(p.wait()) for p in running],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
# Terminate remaining processes
|
# Terminate remaining processes
|
||||||
for p in self._procs:
|
for p in self._procs:
|
||||||
if p.returncode is None:
|
if p.returncode is None:
|
||||||
try:
|
with suppress(ProcessLookupError):
|
||||||
p.terminate()
|
p.terminate()
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Wait for all to finish (with overall timeout)
|
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||||
still_running = [p for p in self._procs if p.returncode is None]
|
still_running = [p for p in self._procs if p.returncode is None]
|
||||||
if still_running:
|
if still_running:
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await asyncio.wait_for(
|
try:
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
await asyncio.shield(
|
||||||
timeout=10,
|
asyncio.wait_for(
|
||||||
)
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
except TimeoutError:
|
timeout=10,
|
||||||
for p in self._procs:
|
),
|
||||||
if p.returncode is None:
|
)
|
||||||
try:
|
except TimeoutError:
|
||||||
p.kill()
|
for p in self._procs:
|
||||||
except ProcessLookupError:
|
if p.returncode is None:
|
||||||
pass
|
with suppress(ProcessLookupError):
|
||||||
await p.wait()
|
p.kill()
|
||||||
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "") -> None:
|
async def check_ports_free(*urls: str) -> None:
|
||||||
|
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||||
|
|
||||||
|
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||||
|
with suppress(httpx.RequestError):
|
||||||
|
res = await client.get(url, timeout=0.1)
|
||||||
|
server = res.headers.get("server", "server")
|
||||||
|
logger.warning("Conflicting %s already running at %s", server, url)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await asyncio.gather(*[check(client, url) for url in urls])
|
||||||
|
|
||||||
|
|
||||||
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Use empty path to disable the check and make this return immediately.
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
max_attempts = 50
|
if not path:
|
||||||
full_url = f"{url}{path}"
|
return
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
await client.get(full_url, timeout=1.0)
|
await client.get(f"{url}{path}", timeout=1.0)
|
||||||
logger.info("✓ Backend ready!")
|
|
||||||
return
|
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.warning("Backend didn't start in time")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
else:
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
def setup_vite(
|
||||||
|
endpoint: str,
|
||||||
|
default_port: int = 5173,
|
||||||
|
) -> tuple[str, list[str], list[str]]:
|
||||||
"""Parse frontend endpoint and build commands.
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
Returns (url, install_cmd, dev_cmd).
|
Returns (url, install_cmd, dev_cmd).
|
||||||
Raises SystemExit(1) on invalid config.
|
Raises SystemExit(1) on invalid config.
|
||||||
"""
|
"""
|
||||||
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
if "uds" in endpoints[0]:
|
if "uds" in endpoints[0]:
|
||||||
logger.warning("Unix sockets not supported with vite devserver")
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
@@ -121,18 +163,53 @@ def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
|||||||
dev_cmd = find_dev_tool()
|
dev_cmd = find_dev_tool()
|
||||||
if host != "localhost":
|
if host != "localhost":
|
||||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||||
if port != 5173:
|
dev_cmd.append(f"--port={port}")
|
||||||
dev_cmd.append(f"--port={port}")
|
|
||||||
|
|
||||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||||
|
|
||||||
|
|
||||||
def setup_fastapi(
|
def setup_fastapi(
|
||||||
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
|
endpoint: str,
|
||||||
|
module: str,
|
||||||
|
default_port: int = 8000,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build fastapi dev command.
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
Returns (url, cmd).
|
Returns (url, uvicorn_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
module,
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
"--reload",
|
||||||
|
f"--reload-dir={reload_dir}",
|
||||||
|
"--forwarded-allow-ips=*",
|
||||||
|
]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_cli(
|
||||||
|
cli: str,
|
||||||
|
endpoint: str,
|
||||||
|
default_port: int = 8000,
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build CLI command.
|
||||||
|
|
||||||
|
Returns (url, cli_cmd).
|
||||||
Raises SystemExit(1) on invalid config.
|
Raises SystemExit(1) on invalid config.
|
||||||
"""
|
"""
|
||||||
endpoints = parse_endpoint(endpoint, default_port)
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
@@ -144,14 +221,5 @@ def setup_fastapi(
|
|||||||
host = endpoints[0]["host"]
|
host = endpoints[0]["host"]
|
||||||
port = endpoints[0]["port"]
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
cmd = [
|
cmd = [cli, f"--listen={host}:{port}"]
|
||||||
"fastapi",
|
|
||||||
"dev",
|
|
||||||
"--entrypoint",
|
|
||||||
module,
|
|
||||||
"--host",
|
|
||||||
host,
|
|
||||||
"--port",
|
|
||||||
str(port),
|
|
||||||
]
|
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import auth, config
|
||||||
|
from cista.app import use_session
|
||||||
|
from cista.auth import bp as auth_bp
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie_headers(response) -> list[str]:
|
||||||
|
return list(response.headers.get_list("set-cookie"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_header(response, name: str = "cista") -> dict[str, str]:
|
||||||
|
for header in _set_cookie_headers(response):
|
||||||
|
cookie = SimpleCookie()
|
||||||
|
cookie.load(header)
|
||||||
|
morsel = cookie.get(name)
|
||||||
|
if morsel is not None and morsel.value:
|
||||||
|
return {"Cookie": f"{name}={morsel.value}"}
|
||||||
|
raise AssertionError(f"response did not set cookie {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_auth_config(tmp_path: Path):
|
||||||
|
alice = config.User()
|
||||||
|
auth.set_password(alice, "secret")
|
||||||
|
admin = config.User(privileged=True)
|
||||||
|
auth.set_password(admin, "admin-secret")
|
||||||
|
config.config = config.Config(
|
||||||
|
path=tmp_path,
|
||||||
|
listen=":0",
|
||||||
|
public=False,
|
||||||
|
users={"alice": alice, "admin": admin},
|
||||||
|
)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(setup_auth_config: Path):
|
||||||
|
app = Sanic(f"auth-builtins-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
|
||||||
|
@app.on_request
|
||||||
|
async def load_auth_context(request):
|
||||||
|
await use_session(request)
|
||||||
|
|
||||||
|
app.blueprint(auth_bp)
|
||||||
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restricted_page_renders_login_form_when_logged_out(client):
|
||||||
|
_, res = await client.get("/auth/restricted/")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Authentication Required" in res.text
|
||||||
|
assert "Username:" in res.text
|
||||||
|
assert "Password:" in res.text
|
||||||
|
assert "/auth/login" in res.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restricted_page_with_invalid_session_clears_cookie(client):
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/restricted/",
|
||||||
|
headers={"Cookie": "cista=missing-session"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Authentication Required" in res.text
|
||||||
|
assert any("cista=" in header.lower() for header in _set_cookie_headers(res))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_json_login_sets_session_cookie_and_allows_session_authenticated_api_access(
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice", "password": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json == {"data": {"username": "alice", "privileged": False}}
|
||||||
|
|
||||||
|
session_cookie = _cookie_header(res)
|
||||||
|
|
||||||
|
_, tokens_res = await client.get("/auth/tokens", headers=session_cookie)
|
||||||
|
assert tokens_res.status_code == 200
|
||||||
|
assert tokens_res.json == {"tokens": []}
|
||||||
|
|
||||||
|
_, restricted_res = await client.get("/auth/restricted/", headers=session_cookie)
|
||||||
|
assert restricted_res.status_code == 200
|
||||||
|
assert "auth-success" in restricted_res.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_json_login_rejects_missing_fields(client):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert "Missing username or password" in res.json["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_json_login_rejects_invalid_password(client):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice", "password": "wrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert "Invalid password" in res.json["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_login_redirects_and_sets_flash_and_session_cookies(client):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": "alice", "password": "secret"},
|
||||||
|
headers={"Accept": "text/html"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 302
|
||||||
|
assert res.headers["location"] == "/"
|
||||||
|
headers = _set_cookie_headers(res)
|
||||||
|
assert any("cista=" in header.lower() for header in headers)
|
||||||
|
assert any("message=" in header.lower() for header in headers)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logout_json_revokes_the_existing_session(client):
|
||||||
|
_, login_res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice", "password": "secret"},
|
||||||
|
)
|
||||||
|
session_cookie = _cookie_header(login_res)
|
||||||
|
|
||||||
|
_, logout_res = await client.post("/auth/api/logout", headers=session_cookie)
|
||||||
|
|
||||||
|
assert logout_res.status_code == 200
|
||||||
|
assert logout_res.json == {"message": "Logged out"}
|
||||||
|
assert any("cista=" in header.lower() for header in _set_cookie_headers(logout_res))
|
||||||
|
|
||||||
|
_, retry_res = await client.get("/auth/tokens", headers=session_cookie)
|
||||||
|
assert retry_res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logout_without_session_reports_not_logged_in(client):
|
||||||
|
_, res = await client.post("/auth/api/logout")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json == {"message": "Not logged in"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_password_change_updates_credentials_and_reissues_session(client):
|
||||||
|
_, change_res = await client.post(
|
||||||
|
"/auth/password-change",
|
||||||
|
json={
|
||||||
|
"username": "alice",
|
||||||
|
"password": "secret",
|
||||||
|
"passwordChange": "fresh-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert change_res.status_code == 200
|
||||||
|
assert change_res.json == {"message": "Password updated"}
|
||||||
|
|
||||||
|
session_cookie = _cookie_header(change_res)
|
||||||
|
_, tokens_res = await client.get("/auth/tokens", headers=session_cookie)
|
||||||
|
assert tokens_res.status_code == 200
|
||||||
|
|
||||||
|
_, old_login_res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice", "password": "secret"},
|
||||||
|
)
|
||||||
|
assert old_login_res.status_code == 403
|
||||||
|
|
||||||
|
_, new_login_res = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "alice", "password": "fresh-secret"},
|
||||||
|
)
|
||||||
|
assert new_login_res.status_code == 200
|
||||||
|
assert new_login_res.json == {"data": {"username": "alice", "privileged": False}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_password_change_rejects_wrong_current_password(client):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/password-change",
|
||||||
|
json={
|
||||||
|
"username": "alice",
|
||||||
|
"password": "wrong",
|
||||||
|
"passwordChange": "fresh-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert "Invalid password" in res.json["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_password_change_rejects_missing_fields(client):
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/password-change",
|
||||||
|
json={"username": "alice", "password": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert "Missing username, passwordChange or password" in res.json["message"]
|
||||||
@@ -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,287 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from Crypto.Hash import MD4
|
||||||
|
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."""
|
||||||
|
# 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.put(token, 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() # noqa: ASYNC240
|
||||||
|
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():
|
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
|
assert "key1" not in cache
|
||||||
cache["key1"]
|
cache["key1"]
|
||||||
assert "key1" in cache
|
assert "key1" in cache
|
||||||
|
|
||||||
|
|
||||||
def test_getitem():
|
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"
|
assert cache["key1"].content == "content-key1"
|
||||||
|
|
||||||
|
|
||||||
def test_capacity():
|
def test_capacity():
|
||||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||||
item1 = cache["key1"]
|
item1 = cache["key1"]
|
||||||
cache["key2"]
|
cache["key2"]
|
||||||
cache["key3"]
|
cache["key3"]
|
||||||
@@ -33,7 +33,7 @@ def test_capacity():
|
|||||||
|
|
||||||
|
|
||||||
def test_expiry():
|
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"]
|
item = cache["key1"]
|
||||||
sleep(0.2) # Wait for expiration
|
sleep(0.2) # Wait for expiration
|
||||||
cache.expire_items()
|
cache.expire_items()
|
||||||
@@ -42,7 +42,7 @@ def test_expiry():
|
|||||||
|
|
||||||
|
|
||||||
def test_close():
|
def test_close():
|
||||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
|
||||||
item = cache["key1"]
|
item = cache["key1"]
|
||||||
cache.close()
|
cache.close()
|
||||||
assert "key1" not in cache
|
assert "key1" not in cache
|
||||||
@@ -50,7 +50,7 @@ def test_close():
|
|||||||
|
|
||||||
|
|
||||||
def test_lru_mechanism():
|
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"]
|
item1 = cache["key1"]
|
||||||
item2 = cache["key2"]
|
item2 = cache["key2"]
|
||||||
cache["key1"] # Make key1 recently used
|
cache["key1"] # Make key1 recently used
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
from pathlib import Path, PurePath
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
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():
|
||||||
|
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):
|
||||||
|
config.init_confdir(tmp_path)
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user