Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718d46e3f9 | ||
|
|
92d9c40a28 | ||
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc | ||
|
|
bf8a049b92 | ||
|
|
6d7f44bd88 | ||
|
|
e2097a1563 | ||
|
|
b864936eaa | ||
|
|
72b3c0d8ce | ||
|
|
07daf372e8 | ||
|
|
e979d679b2 | ||
|
|
eea66c0013 | ||
|
|
9220c457c0 | ||
|
|
d5b77932ea | ||
|
|
9b9d3e1cc1 | ||
|
|
536efc4ce1 | ||
|
|
1b2267587f | ||
|
|
2864e9f041 | ||
|
|
b5a94b5eee | ||
|
|
d4be755d46 | ||
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 | ||
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 | ||
|
|
af804e2c9f | ||
|
|
1dc0c4441a | ||
|
|
c7ba0d5a04 | ||
|
|
0071058b29 | ||
|
|
338c74de69 | ||
|
|
b13f08eab2 | ||
|
|
5d566d6deb | ||
|
|
5c4965a86b | ||
|
|
bd7291e9ef | ||
|
|
2fa52229cc | ||
|
|
2dea459d8f | ||
|
|
922069c603 | ||
|
|
5c7c7343ad | ||
|
|
593d16d8c5 | ||
|
|
20d8d317fa | ||
|
|
8d89c397a4 | ||
|
|
1bec73f4cd | ||
|
|
4dd1d4c7e6 | ||
|
|
84ef91a360 | ||
|
|
421d90e9c5 | ||
|
|
8b4e622aef | ||
|
|
13f32c57ab | ||
|
|
25a2a5f20c |
+36
-30
@@ -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,7 +31,7 @@ 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
|
||||||
@@ -53,40 +54,39 @@ def create_startup_box(
|
|||||||
|
|
||||||
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!
|
||||||
"""
|
"""
|
||||||
@@ -115,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>"]:
|
||||||
@@ -153,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,
|
||||||
@@ -171,17 +170,24 @@ 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
|
||||||
|
return confdir
|
||||||
|
|
||||||
|
|
||||||
|
def _confdir(args):
|
||||||
|
confdir = _resolve_confdir(args)
|
||||||
|
if confdir is not None:
|
||||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||||
config.init_confdir() # Uses environ if available
|
config.init_confdir()
|
||||||
|
|
||||||
|
|
||||||
def _user(args):
|
def _user(args):
|
||||||
|
|||||||
+9
-9
@@ -6,7 +6,7 @@ from sanic import Blueprint, json
|
|||||||
from sanic.exceptions import BadRequest
|
from sanic.exceptions import BadRequest
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import __version__, auth, config, sharefs, sso, watching
|
from cista import __version__, auth, config, onlyoffice, sharefs, sso, watching
|
||||||
from cista.auth import (
|
from cista.auth import (
|
||||||
create_share_token_handler,
|
create_share_token_handler,
|
||||||
create_token_handler,
|
create_token_handler,
|
||||||
@@ -22,11 +22,13 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -99,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
|
||||||
@@ -125,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})
|
||||||
|
|
||||||
@@ -140,12 +142,10 @@ 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
|
||||||
|
|||||||
+36
-11
@@ -8,6 +8,7 @@ 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 tracerite
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Sanic, empty, raw, redirect
|
from sanic import Sanic, empty, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
@@ -16,7 +17,17 @@ 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, fileserver, preview, session, sharefs, 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.preview import shutdown_preview_workers, start_preview_workers
|
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||||
from cista.sanic_logging import (
|
from cista.sanic_logging import (
|
||||||
@@ -27,8 +38,10 @@ from cista.sanic_logging import (
|
|||||||
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
|
||||||
|
|
||||||
|
tracerite.load()
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
|
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.router.ALLOWED_METHODS = (
|
app.router.ALLOWED_METHODS = (
|
||||||
*app.router.ALLOWED_METHODS,
|
*app.router.ALLOWED_METHODS,
|
||||||
@@ -43,8 +56,8 @@ configure_main_logging()
|
|||||||
|
|
||||||
@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._auth_flow = ["session: start"]
|
req.ctx.auth_flow = ["session: start"]
|
||||||
auth.hydrate_request_auth_context(req, source="app.on_request")
|
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
@@ -61,7 +74,7 @@ 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.client_ip or "-"
|
client = req.client_ip or "-"
|
||||||
host = req.host or "-"
|
host = req.host or "-"
|
||||||
@@ -71,7 +84,7 @@ 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}"
|
||||||
extra = getattr(req.ctx, "_log_extra", None)
|
extra = getattr(req.ctx, "log_extra", None)
|
||||||
line = format_access_log(
|
line = format_access_log(
|
||||||
client, res.status, req.method, host, path, duration_ms, extra=extra
|
client, res.status, req.method, host, path, duration_ms, extra=extra
|
||||||
)
|
)
|
||||||
@@ -90,7 +103,7 @@ async def forward_sso_cookies(req, res):
|
|||||||
@app.on_response
|
@app.on_response
|
||||||
async def persist_auth_session(req, res):
|
async def persist_auth_session(req, res):
|
||||||
"""Persist a session cookie after successful Authorization-based auth."""
|
"""Persist a session cookie after successful Authorization-based auth."""
|
||||||
username = getattr(req.ctx, "_create_session_username", None)
|
username = getattr(req.ctx, "create_session_username", None)
|
||||||
if not username or res.status >= 400:
|
if not username or res.status >= 400:
|
||||||
return
|
return
|
||||||
existing = getattr(req.ctx, "session", None)
|
existing = getattr(req.ctx, "session", None)
|
||||||
@@ -126,14 +139,25 @@ async def main_start(app):
|
|||||||
watching.start(app)
|
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)
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app):
|
async def main_stop(app):
|
||||||
watching.stop(app)
|
async with asyncio.TaskGroup() as tg:
|
||||||
await shutdown_preview_workers()
|
tg.create_task(asyncio.to_thread(watching.stop, app))
|
||||||
app.ctx.threadexec.shutdown()
|
tg.create_task(onlyoffice.close_oo_client())
|
||||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
tg.create_task(shutdown_preview_workers())
|
||||||
await sso.close_client()
|
tg.create_task(sso.close_client())
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
|
||||||
|
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
@@ -236,6 +260,7 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+74
-76
@@ -2,22 +2,21 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
import struct
|
import struct
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from time import time
|
from time import time
|
||||||
from unicodedata import normalize
|
|
||||||
|
|
||||||
import argon2
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from Crypto.Hash import MD4
|
||||||
from html5tagger import Document
|
from html5tagger import Document
|
||||||
from sanic import Blueprint, html, json, redirect
|
from sanic import Blueprint, html, json, redirect
|
||||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import config, session, sharefs
|
from cista import config, session, sharefs
|
||||||
from cista.util import pwgen
|
from cista import sso as _sso_module
|
||||||
|
from cista.util import pwgen, pwhash
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
_LOGIN_PAGE_CSS = """\
|
_LOGIN_PAGE_CSS = """\
|
||||||
@@ -175,16 +174,8 @@ form.onsubmit = async (e) => {
|
|||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Import for SSO validation (lazily loaded to avoid circular imports)
|
|
||||||
_sso_module = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_sso():
|
def _get_sso():
|
||||||
global _sso_module
|
|
||||||
if _sso_module is None:
|
|
||||||
from cista import sso
|
|
||||||
|
|
||||||
_sso_module = sso
|
|
||||||
return _sso_module
|
return _sso_module
|
||||||
|
|
||||||
|
|
||||||
@@ -202,13 +193,13 @@ def _set_auth_failure_log(request, auth_flow: list[str]) -> None:
|
|||||||
value = request.headers.get(header)
|
value = request.headers.get(header)
|
||||||
if value:
|
if value:
|
||||||
parts.append(f"{label}={value}")
|
parts.append(f"{label}={value}")
|
||||||
request.ctx._log_extra = " | ".join(parts)
|
request.ctx.log_extra = " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def hydrate_request_auth_context(request, *, source: str) -> None:
|
def hydrate_request_auth_context(request, *, source: str) -> None:
|
||||||
auth_flow = getattr(request.ctx, "_auth_flow", None)
|
auth_flow = getattr(request.ctx, "auth_flow", None)
|
||||||
if auth_flow is None:
|
if auth_flow is None:
|
||||||
auth_flow = request.ctx._auth_flow = []
|
auth_flow = request.ctx.auth_flow = []
|
||||||
|
|
||||||
if hasattr(request.ctx, "session"):
|
if hasattr(request.ctx, "session"):
|
||||||
# Already hydrated by an earlier caller (e.g., use_session middleware)
|
# Already hydrated by an earlier caller (e.g., use_session middleware)
|
||||||
@@ -234,9 +225,6 @@ def hydrate_request_auth_context(request, *, source: str) -> None:
|
|||||||
auth_flow.append(f"session:{source}(bad-jwt)")
|
auth_flow.append(f"session:{source}(bad-jwt)")
|
||||||
|
|
||||||
|
|
||||||
_argon = argon2.PasswordHasher()
|
|
||||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
|
||||||
|
|
||||||
_AUTH_REALM = "cista"
|
_AUTH_REALM = "cista"
|
||||||
_AUTH_CACHE_TTL = 10
|
_AUTH_CACHE_TTL = 10
|
||||||
_auth_cache: dict[str, tuple[float, config.User]] = {}
|
_auth_cache: dict[str, tuple[float, config.User]] = {}
|
||||||
@@ -280,6 +268,7 @@ def _log_webdav_user_agent_once(request, user_agent: str):
|
|||||||
|
|
||||||
|
|
||||||
def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]:
|
def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]:
|
||||||
|
_ = include_hint
|
||||||
user_agent = request.headers.get("user-agent", "")
|
user_agent = request.headers.get("user-agent", "")
|
||||||
_log_webdav_user_agent_once(request, user_agent)
|
_log_webdav_user_agent_once(request, user_agent)
|
||||||
if _is_windows_auth_client(user_agent):
|
if _is_windows_auth_client(user_agent):
|
||||||
@@ -448,12 +437,6 @@ def _ntlmv2_verify(
|
|||||||
nt_response: bytes,
|
nt_response: bytes,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Verify an NTLMv2 response using the plaintext token secret as the password."""
|
"""Verify an NTLMv2 response using the plaintext token secret as the password."""
|
||||||
try:
|
|
||||||
from Crypto.Hash import MD4
|
|
||||||
except ImportError:
|
|
||||||
logger.error("pycryptodome MD4 not available, cannot verify NTLM")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if len(nt_response) < 16:
|
if len(nt_response) < 16:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -517,47 +500,30 @@ def _ntlmv2_verify(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _pwnorm(password):
|
|
||||||
return normalize("NFC", password).strip().encode()
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_key(username: str, password: str) -> str:
|
def _cache_key(username: str, password: str) -> str:
|
||||||
return hashlib.sha256(f"{username}\x00{password}".encode()).hexdigest()
|
return hashlib.sha256(f"{username}\x00{password}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def login(username: str, password: str):
|
def login(username: str, password: str):
|
||||||
|
normalized_username = pwhash.normalize_secret(username).decode()
|
||||||
cache_key = _cache_key(username, password)
|
cache_key = _cache_key(username, password)
|
||||||
cached = _auth_cache.get(cache_key)
|
cached = _auth_cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
ts, user = cached
|
ts, user = cached
|
||||||
if time() - ts < _AUTH_CACHE_TTL:
|
if time() - ts < _AUTH_CACHE_TTL:
|
||||||
return user
|
current = config.config.users.get(normalized_username)
|
||||||
|
if current and current.hash == user.hash:
|
||||||
|
return current
|
||||||
del _auth_cache[cache_key]
|
del _auth_cache[cache_key]
|
||||||
|
|
||||||
un = _pwnorm(username)
|
|
||||||
pw = _pwnorm(password)
|
|
||||||
try:
|
try:
|
||||||
u = config.config.users[un.decode()]
|
u = config.config.users[normalized_username]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError("Invalid username") from None
|
raise ValueError("Invalid username") from None
|
||||||
# Verify password
|
# Verify password
|
||||||
need_rehash = False
|
need_rehash = pwhash.verify_hash(
|
||||||
if not u.hash:
|
u.hash, username=normalized_username, password=password
|
||||||
raise ValueError("Account disabled")
|
)
|
||||||
if (m := _droppyhash.match(u.hash)) is not None:
|
|
||||||
h, s = m.groups()
|
|
||||||
h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex()
|
|
||||||
if not hmac.compare_digest(h, h2):
|
|
||||||
raise ValueError("Invalid password")
|
|
||||||
# Droppy hashes are weak, do a hash update
|
|
||||||
need_rehash = True
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
_argon.verify(u.hash, pw)
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("Invalid password") from None
|
|
||||||
if _argon.check_needs_rehash(u.hash):
|
|
||||||
need_rehash = True
|
|
||||||
# Login successful
|
# Login successful
|
||||||
if need_rehash:
|
if need_rehash:
|
||||||
set_password(u, password)
|
set_password(u, password)
|
||||||
@@ -568,7 +534,7 @@ def login(username: str, password: str):
|
|||||||
|
|
||||||
|
|
||||||
def set_password(user: config.User, password: str):
|
def set_password(user: config.User, password: str):
|
||||||
user.hash = _argon.hash(_pwnorm(password))
|
pwhash.set_password(user, password)
|
||||||
_auth_cache.clear()
|
_auth_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -608,6 +574,13 @@ def _basic_auth_login(request):
|
|||||||
if username == "token":
|
if username == "token":
|
||||||
token = config.config.tokens.get(password)
|
token = config.config.tokens.get(password)
|
||||||
if token:
|
if token:
|
||||||
|
if _allow_anonymous_share_token(token):
|
||||||
|
request.ctx.session = None
|
||||||
|
request.ctx.username = None
|
||||||
|
request.ctx.user = None
|
||||||
|
request.ctx.auth_token_id = password
|
||||||
|
request.ctx.auth_token = token
|
||||||
|
return None
|
||||||
user = config.config.users.get(token.username)
|
user = config.config.users.get(token.username)
|
||||||
if user:
|
if user:
|
||||||
request.ctx.session = None
|
request.ctx.session = None
|
||||||
@@ -670,11 +643,12 @@ async def _token_auth_login(request, *, privileged=False):
|
|||||||
ctx = data.get("ctx", {}) if isinstance(data, dict) else {}
|
ctx = data.get("ctx", {}) if isinstance(data, dict) else {}
|
||||||
user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {}
|
user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {}
|
||||||
request.ctx.username = user_info.get("display_name", "")
|
request.ctx.username = user_info.get("display_name", "")
|
||||||
return True
|
|
||||||
except Forbidden:
|
except Forbidden:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
if token.username:
|
if token.username:
|
||||||
user = config.config.users.get(token.username)
|
user = config.config.users.get(token.username)
|
||||||
@@ -840,12 +814,13 @@ async def _ntlm_auth_login(request, *, privileged=False):
|
|||||||
token.sso_user_id,
|
token.sso_user_id,
|
||||||
tid[:8],
|
tid[:8],
|
||||||
)
|
)
|
||||||
return True
|
|
||||||
except Forbidden:
|
except Forbidden:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("NTLM SSO check failed: %s", e)
|
logger.warning("NTLM SSO check failed: %s", e)
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
if token.username:
|
if token.username:
|
||||||
user = config.config.users.get(token.username)
|
user = config.config.users.get(token.username)
|
||||||
@@ -865,7 +840,7 @@ async def _ntlm_auth_login(request, *, privileged=False):
|
|||||||
request.ctx.user = user
|
request.ctx.user = user
|
||||||
request.ctx.auth_token_id = tid
|
request.ctx.auth_token_id = tid
|
||||||
request.ctx.auth_token = token
|
request.ctx.auth_token = token
|
||||||
request.ctx._create_session_username = token.username
|
request.ctx.create_session_username = token.username
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"NTLM auth success for local user %s (token=%s...)",
|
"NTLM auth success for local user %s (token=%s...)",
|
||||||
token.username,
|
token.username,
|
||||||
@@ -905,16 +880,18 @@ async def verify(request, *, privileged=False):
|
|||||||
"""
|
"""
|
||||||
hydrate_request_auth_context(request, source="auth.verify")
|
hydrate_request_auth_context(request, source="auth.verify")
|
||||||
|
|
||||||
# Public mode: skip auth unless privileged access is required
|
|
||||||
if config.config.public and not privileged:
|
|
||||||
return
|
|
||||||
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
has_auth_header = bool(auth_header)
|
has_auth_header = bool(auth_header)
|
||||||
scheme = auth_header.split()[0].lower() if has_auth_header else None
|
scheme = auth_header.split()[0].lower() if has_auth_header else None
|
||||||
|
|
||||||
|
# Public mode: skip auth unless privileged access is required.
|
||||||
|
# Still parse explicit Authorization headers so share-token URLs can
|
||||||
|
# activate share scoping even while public access is enabled.
|
||||||
|
if config.config.public and not privileged and not has_auth_header:
|
||||||
|
return
|
||||||
|
|
||||||
# Concise auth flow for diagnostics (populated by use_session + verify)
|
# Concise auth flow for diagnostics (populated by use_session + verify)
|
||||||
auth_flow = list(getattr(request.ctx, "_auth_flow", ["session:skipped"]))
|
auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"]))
|
||||||
tried: list[str] = []
|
tried: list[str] = []
|
||||||
|
|
||||||
sso = _get_sso()
|
sso = _get_sso()
|
||||||
@@ -927,7 +904,6 @@ async def verify(request, *, privileged=False):
|
|||||||
try:
|
try:
|
||||||
perm = "cista:admin" if privileged else "cista:login"
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
await sso.validate_sso_request(request, perm=perm)
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
return
|
|
||||||
except Unauthorized as e:
|
except Unauthorized as e:
|
||||||
auth_flow.append(f"tried={','.join(tried)} result=failed")
|
auth_flow.append(f"tried={','.join(tried)} result=failed")
|
||||||
_set_auth_failure_log(request, auth_flow)
|
_set_auth_failure_log(request, auth_flow)
|
||||||
@@ -936,6 +912,8 @@ async def verify(request, *, privileged=False):
|
|||||||
headers=_build_ua_auth_headers(request),
|
headers=_build_ua_auth_headers(request),
|
||||||
quiet=True,
|
quiet=True,
|
||||||
) from e
|
) from e
|
||||||
|
else:
|
||||||
|
return
|
||||||
tried.append("sso")
|
tried.append("sso")
|
||||||
perm = "cista:admin" if privileged else "cista:login"
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
await sso.validate_sso_request(request, perm=perm)
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
@@ -972,6 +950,13 @@ async def verify(request, *, privileged=False):
|
|||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
token = request_share_token(request)
|
||||||
|
if (
|
||||||
|
token is not None
|
||||||
|
and _allow_anonymous_share_token(token)
|
||||||
|
and not privileged
|
||||||
|
):
|
||||||
|
return
|
||||||
elif scheme in ("ntlm", "negotiate"):
|
elif scheme in ("ntlm", "negotiate"):
|
||||||
tried.append("ntlm")
|
tried.append("ntlm")
|
||||||
try:
|
try:
|
||||||
@@ -986,10 +971,10 @@ async def verify(request, *, privileged=False):
|
|||||||
user = None
|
user = None
|
||||||
else:
|
else:
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if getattr(request.ctx, "_create_session_username", None) is None:
|
if getattr(request.ctx, "create_session_username", None) is None:
|
||||||
username = getattr(request.ctx, "username", None)
|
username = getattr(request.ctx, "username", None)
|
||||||
if username:
|
if username:
|
||||||
request.ctx._create_session_username = username
|
request.ctx.create_session_username = username
|
||||||
return
|
return
|
||||||
# Auth header present but invalid → try session fallback
|
# Auth header present but invalid → try session fallback
|
||||||
tried.append("session")
|
tried.append("session")
|
||||||
@@ -1113,6 +1098,7 @@ async def login_page(request):
|
|||||||
|
|
||||||
def _login_success_page(username: str) -> str:
|
def _login_success_page(username: str) -> str:
|
||||||
"""Minimal page that signals auth-success to parent iframe."""
|
"""Minimal page that signals auth-success to parent iframe."""
|
||||||
|
_ = username
|
||||||
return str(
|
return str(
|
||||||
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
||||||
)
|
)
|
||||||
@@ -1127,13 +1113,16 @@ async def login_post(request):
|
|||||||
else:
|
else:
|
||||||
username = request.form["username"][0]
|
username = request.form["username"][0]
|
||||||
password = request.form["password"][0]
|
password = request.form["password"][0]
|
||||||
if not username or not password:
|
|
||||||
raise KeyError
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise BadRequest(
|
raise BadRequest(
|
||||||
"Missing username or password",
|
"Missing username or password",
|
||||||
context={"redirect": "/login"},
|
context={"redirect": "/login"},
|
||||||
) from None
|
) from None
|
||||||
|
if not username or not password:
|
||||||
|
raise BadRequest(
|
||||||
|
"Missing username or password",
|
||||||
|
context={"redirect": "/login"},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
user = login(username, password)
|
user = login(username, password)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1172,12 +1161,12 @@ async def change_password(request):
|
|||||||
username = request.form["username"][0]
|
username = request.form["username"][0]
|
||||||
pwchange = request.form["passwordChange"][0]
|
pwchange = request.form["passwordChange"][0]
|
||||||
password = request.form["password"][0]
|
password = request.form["password"][0]
|
||||||
if not username or not password:
|
|
||||||
raise KeyError
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise BadRequest(
|
raise BadRequest(
|
||||||
"Missing username, passwordChange or password",
|
"Missing username, passwordChange or password",
|
||||||
) from None
|
) from None
|
||||||
|
if not username or not password:
|
||||||
|
raise BadRequest("Missing username, passwordChange or password")
|
||||||
try:
|
try:
|
||||||
user = login(username, password)
|
user = login(username, password)
|
||||||
set_password(user, pwchange)
|
set_password(user, pwchange)
|
||||||
@@ -1220,16 +1209,15 @@ async def create_user(request):
|
|||||||
username = request.form["username"][0]
|
username = request.form["username"][0]
|
||||||
password = request.form.get("password", [None])[0]
|
password = request.form.get("password", [None])[0]
|
||||||
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
|
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
|
||||||
if not username or not username.isidentifier():
|
except KeyError as e:
|
||||||
raise ValueError("Invalid username")
|
raise BadRequest("Missing fields") from e
|
||||||
except (KeyError, ValueError) as e:
|
if not username or not username.isidentifier():
|
||||||
raise BadRequest(str(e)) from e
|
raise BadRequest("Invalid username")
|
||||||
if username in config.config.users:
|
if username in config.config.users:
|
||||||
raise BadRequest("User already exists")
|
raise BadRequest("User already exists")
|
||||||
if not password:
|
if not password:
|
||||||
password = pwgen.generate()
|
password = pwgen.generate()
|
||||||
changes = {"privileged": privileged}
|
changes = {"privileged": privileged, "password": password}
|
||||||
changes["hash"] = _argon.hash(_pwnorm(password))
|
|
||||||
try:
|
try:
|
||||||
config.update_user(username, changes)
|
config.update_user(username, changes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1256,8 +1244,6 @@ async def update_user(request, username):
|
|||||||
if changes["password"] == "":
|
if changes["password"] == "":
|
||||||
changes["password"] = pwgen.generate()
|
changes["password"] = pwgen.generate()
|
||||||
password_response = changes["password"]
|
password_response = changes["password"]
|
||||||
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
|
|
||||||
del changes["password"]
|
|
||||||
if not changes:
|
if not changes:
|
||||||
return json({"message": "No changes"})
|
return json({"message": "No changes"})
|
||||||
try:
|
try:
|
||||||
@@ -1305,6 +1291,17 @@ def _token_belongs_to_user(token, username, sso_user_id):
|
|||||||
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
|
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_anonymous_share_token(token: config.Token) -> bool:
|
||||||
|
return (
|
||||||
|
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_anonymous_share_token(token: config.Token) -> bool:
|
||||||
|
# Anonymous share links are intentionally coupled to public mode.
|
||||||
|
return config.config.public and _is_anonymous_share_token(token)
|
||||||
|
|
||||||
|
|
||||||
def request_token(request) -> config.Token | None:
|
def request_token(request) -> config.Token | None:
|
||||||
token = getattr(request.ctx, "auth_token", None)
|
token = getattr(request.ctx, "auth_token", None)
|
||||||
return token if isinstance(token, config.Token) else None
|
return token if isinstance(token, config.Token) else None
|
||||||
@@ -1469,10 +1466,11 @@ async def create_share_token_handler(request):
|
|||||||
raise BadRequest("Could not determine SSO user")
|
raise BadRequest("Could not determine SSO user")
|
||||||
else:
|
else:
|
||||||
username = current_username or ""
|
username = current_username or ""
|
||||||
if not username:
|
if username:
|
||||||
|
if username not in config.config.users:
|
||||||
|
raise BadRequest("User does not exist")
|
||||||
|
elif not config.config.public:
|
||||||
raise BadRequest("Could not determine user")
|
raise BadRequest("Could not determine user")
|
||||||
if username not in config.config.users:
|
|
||||||
raise BadRequest("User does not exist")
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(12)
|
token = secrets.token_urlsafe(12)
|
||||||
changes = {
|
changes = {
|
||||||
|
|||||||
+3
-3
@@ -14,6 +14,8 @@ 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
|
||||||
@@ -199,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)
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
+15
-2
@@ -1,9 +1,11 @@
|
|||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
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.diskspace import InsufficientStorageError, check_free_space
|
||||||
from cista.util.lrucache import LRUCache
|
from cista.util.lrucache import LRUCache
|
||||||
|
|
||||||
|
|
||||||
@@ -34,13 +36,24 @@ class File:
|
|||||||
self.open_rw()
|
self.open_rw()
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
raise RuntimeError("file descriptor is not available for write")
|
raise RuntimeError("file descriptor is not available for write")
|
||||||
|
check_free_space(self.path)
|
||||||
if file_size is not None:
|
if file_size is not None:
|
||||||
if pos + len(buffer) > file_size:
|
if pos + len(buffer) > file_size:
|
||||||
raise ValueError("write exceeds declared file size")
|
raise ValueError("write exceeds declared file size")
|
||||||
os.ftruncate(self.fd, file_size)
|
try:
|
||||||
|
os.ftruncate(self.fd, file_size)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
if buffer:
|
if buffer:
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
os.write(self.fd, buffer)
|
try:
|
||||||
|
os.write(self.fd, buffer)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
|
|
||||||
def __getitem__(self, slc):
|
def __getitem__(self, slc):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
|
|||||||
+25
-32
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import errno
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -12,11 +13,12 @@ from urllib.parse import unquote, urlparse
|
|||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
from sanic import Blueprint, HTTPResponse, empty, json
|
from sanic import Blueprint, HTTPResponse, empty, json
|
||||||
from sanic.exceptions import BadRequest, NotFound
|
from sanic.exceptions import BadRequest, NotFound, SanicException
|
||||||
|
|
||||||
from cista import auth, config, sharefs, watching
|
from cista import auth, config, sharefs, watching
|
||||||
from cista.api import fileserver
|
from cista.api import fileserver
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
|
from cista.util.diskspace import InsufficientStorageError
|
||||||
|
|
||||||
bp = Blueprint("fileserver", url_prefix="/files")
|
bp = Blueprint("fileserver", url_prefix="/files")
|
||||||
|
|
||||||
@@ -52,13 +54,22 @@ async def upload_file_chunk(request, name):
|
|||||||
|
|
||||||
rel, path = _safe_relpath(name, request=request)
|
rel, path = _safe_relpath(name, request=request)
|
||||||
rel_name = rel.as_posix()
|
rel_name = rel.as_posix()
|
||||||
upload_info = await asyncio.to_thread(
|
try:
|
||||||
fileserver.upload_info,
|
upload_info = await asyncio.to_thread(
|
||||||
rel_name,
|
fileserver.upload_info,
|
||||||
start,
|
rel_name,
|
||||||
body,
|
start,
|
||||||
total,
|
body,
|
||||||
)
|
total,
|
||||||
|
)
|
||||||
|
except InsufficientStorageError as e:
|
||||||
|
raise SanicException(str(e), status_code=507, quiet=True) from e
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise SanicException(
|
||||||
|
"No space left on device", status_code=507, quiet=True
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
extras = []
|
extras = []
|
||||||
chunk_len = end - start
|
chunk_len = end - start
|
||||||
whole_file = start == 0 and end == total
|
whole_file = start == 0 and end == total
|
||||||
@@ -76,7 +87,7 @@ async def upload_file_chunk(request, name):
|
|||||||
size_after = upload_info.get("size_after")
|
size_after = upload_info.get("size_after")
|
||||||
if size_before is not None and size_after is not None and size_before != size_after:
|
if size_before is not None and size_after is not None and size_before != size_after:
|
||||||
extras.append("resized")
|
extras.append("resized")
|
||||||
request.ctx._log_extra = " ".join(extras) if extras else None
|
request.ctx.log_extra = " ".join(extras) if extras else None
|
||||||
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
|
real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
|
||||||
watching.notify_change(real_rel, *real_rel.parents)
|
watching.notify_change(real_rel, *real_rel.parents)
|
||||||
return json(
|
return json(
|
||||||
@@ -197,38 +208,18 @@ async def copy_or_move(request, name=""):
|
|||||||
|
|
||||||
def _apply():
|
def _apply():
|
||||||
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
||||||
op_multi = len(op_keys) > 1
|
|
||||||
for key in op_keys:
|
for key in op_keys:
|
||||||
try:
|
try:
|
||||||
src_rel = key_paths[key]
|
src_rel = key_paths[key]
|
||||||
src_abs = _resolve_from_relpath(src_rel, request=request)
|
src_abs = _resolve_from_relpath(src_rel, request=request)
|
||||||
|
|
||||||
if op_multi:
|
if dst_is_dir:
|
||||||
if not dst_is_dir:
|
|
||||||
raise BadRequest(
|
|
||||||
"Destination must be an existing directory for multiple keys"
|
|
||||||
)
|
|
||||||
dst_item_rel = (
|
|
||||||
dst_rel / src_rel.name
|
|
||||||
if dst_rel.parts
|
|
||||||
else PurePosixPath(src_rel.name)
|
|
||||||
)
|
|
||||||
elif dst_is_dir:
|
|
||||||
dst_item_rel = (
|
dst_item_rel = (
|
||||||
dst_rel / src_rel.name
|
dst_rel / src_rel.name
|
||||||
if dst_rel.parts
|
if dst_rel.parts
|
||||||
else PurePosixPath(src_rel.name)
|
else PurePosixPath(src_rel.name)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not dst_rel.parts:
|
|
||||||
raise BadRequest("Destination file path is required")
|
|
||||||
parent_abs = dst_abs.parent
|
|
||||||
if not parent_abs.is_dir():
|
|
||||||
raise BadRequest("Destination parent folder does not exist")
|
|
||||||
if src_abs.is_dir() and dst_exists and dst_abs.is_file():
|
|
||||||
raise BadRequest(
|
|
||||||
"Cannot move/copy a directory to an existing file"
|
|
||||||
)
|
|
||||||
dst_item_rel = dst_rel
|
dst_item_rel = dst_rel
|
||||||
|
|
||||||
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
|
dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
|
||||||
@@ -301,6 +292,8 @@ async def head_file(request, name=""):
|
|||||||
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
||||||
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
||||||
async def dav_options(request, name=""):
|
async def dav_options(request, name=""):
|
||||||
|
_ = request
|
||||||
|
_ = name
|
||||||
return HTTPResponse(
|
return HTTPResponse(
|
||||||
status=200,
|
status=200,
|
||||||
headers={
|
headers={
|
||||||
@@ -362,7 +355,7 @@ async def dav_copy(request, name=""):
|
|||||||
dst_rel, dst_abs = _parse_webdav_destination(dest_header, 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:
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
raise BadRequest("Destination cannot be virtual root")
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
request.ctx._log_extra = f"→ {dst_rel}"
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
if not src_abs.exists():
|
if not src_abs.exists():
|
||||||
raise NotFound(f"Source not found: {name}")
|
raise NotFound(f"Source not found: {name}")
|
||||||
if src_abs == dst_abs:
|
if src_abs == dst_abs:
|
||||||
@@ -401,7 +394,7 @@ async def dav_move(request, name=""):
|
|||||||
dst_rel, dst_abs = _parse_webdav_destination(dest_header, 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:
|
if auth.request_share_token(request) is not None and not dst_rel.parts:
|
||||||
raise BadRequest("Destination cannot be virtual root")
|
raise BadRequest("Destination cannot be virtual root")
|
||||||
request.ctx._log_extra = f"→ {dst_rel}"
|
request.ctx.log_extra = f"→ {dst_rel}"
|
||||||
if not src_abs.exists():
|
if not src_abs.exists():
|
||||||
raise NotFound(f"Source not found: {name}")
|
raise NotFound(f"Source not found: {name}")
|
||||||
if src_abs == dst_abs:
|
if src_abs == dst_abs:
|
||||||
|
|||||||
+159
-24
@@ -10,12 +10,14 @@ Environment requirements:
|
|||||||
reachable from the container (usually the docker bridge IP).
|
reachable from the container (usually the docker bridge IP).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from http.server import SimpleHTTPRequestHandler
|
from http.server import SimpleHTTPRequestHandler
|
||||||
@@ -23,20 +25,29 @@ from pathlib import Path
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_httpx_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
|
return os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988")
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt_secret() -> str | None:
|
def _get_jwt_secret() -> str:
|
||||||
return os.environ.get("ONLYOFFICE_JWT_SECRET") or None
|
return (
|
||||||
|
os.environ.get("ONLYOFFICE_JWT_SECRET")
|
||||||
|
or config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_callback_host() -> str:
|
def _get_callback_host() -> str:
|
||||||
@@ -62,19 +73,142 @@ def _get_callback_host() -> str:
|
|||||||
return "127.0.0.1"
|
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
|
# Availability check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
def _probe_status() -> tuple[bool, bool, str | None]:
|
||||||
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
"""Return (ok, responded, detail) for a lightweight reachability probe."""
|
||||||
url = _get_onlyoffice_url()
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
|
with urllib.request.urlopen(url, timeout=2) as resp: # noqa: S310
|
||||||
return resp.status == 200
|
status = resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status = e.code
|
||||||
|
except Exception:
|
||||||
|
return False, False, None
|
||||||
|
|
||||||
|
if status in (200, 405):
|
||||||
|
return True, True, None
|
||||||
|
if status >= 500:
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
return False, True, f"HTTP {status}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_reachable_info() -> None:
|
||||||
|
"""Log info on success, warning on responded probe errors, silent on no-response."""
|
||||||
|
ok, responded, detail = _probe_status()
|
||||||
|
if ok:
|
||||||
|
logger.info("Using OnlyOffice document server at %s", _get_onlyoffice_url())
|
||||||
|
elif responded:
|
||||||
|
suffix = f": {detail}" if detail else ""
|
||||||
|
logger.warning("OnlyOffice probe failed%s", suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_docker(confdir: Path | None = None) -> int:
|
||||||
|
"""Build and run the patched OnlyOffice Docker image."""
|
||||||
|
if confdir is not None:
|
||||||
|
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||||
|
config.init_confdir()
|
||||||
|
if config.conffile.exists():
|
||||||
|
config.load_config()
|
||||||
|
else:
|
||||||
|
config.update_config(
|
||||||
|
{
|
||||||
|
"listen": ":8989",
|
||||||
|
"path": Path.home() / "Downloads",
|
||||||
|
"public": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = config.derived_secret("onlyoffice", size=16).hex()
|
||||||
|
docker_dir = Path(__file__).parent / "docker"
|
||||||
|
if not docker_dir.is_dir():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Docker files not found at {docker_dir}. Is the package installed correctly?"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Building OnlyOffice image")
|
||||||
|
build_cmd = ["docker", "build", "-t", "onlyoffice-cista", str(docker_dir)]
|
||||||
|
logger.info("%s", " ".join(build_cmd))
|
||||||
|
result = subprocess.run(build_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to build OnlyOffice image")
|
||||||
|
|
||||||
|
logger.info("Starting OnlyOffice container")
|
||||||
|
run_cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"-p",
|
||||||
|
"8988:80",
|
||||||
|
"-e",
|
||||||
|
f"JWT_SECRET={secret}",
|
||||||
|
"-e",
|
||||||
|
"WORKERS=8",
|
||||||
|
"--name",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"onlyoffice-cista",
|
||||||
|
]
|
||||||
|
logger.info("%s", " ".join(run_cmd))
|
||||||
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Failed to start OnlyOffice container")
|
||||||
|
logger.info("OnlyOffice is running on http://localhost:8988")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_async(request_timeout: float = 2.0) -> bool:
|
||||||
|
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=request_timeout)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -121,22 +255,23 @@ def _build_jwt_token(payload: dict) -> str | None:
|
|||||||
return jwt.encode(payload, secret, algorithm="HS256")
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
|
||||||
"""Convert *file_path* to PNG using OnlyOffice Document Server.
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
Returns the PNG bytes. Raises RuntimeError on failure.
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
oo_url = _get_onlyoffice_url().rstrip("/")
|
oo_url = _get_onlyoffice_url().rstrip("/")
|
||||||
convert_url = f"{oo_url}/ConvertService.ashx"
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
|
||||||
# Start temporary HTTP server so OnlyOffice can fetch the file
|
# Start temporary HTTP server so OnlyOffice can fetch the file
|
||||||
doc_url, httpd = _serve_file_temporarily(file_path)
|
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
||||||
try:
|
try:
|
||||||
suffix = file_path.suffix.lstrip(".").lower()
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
payload = {
|
payload = {
|
||||||
"async": False,
|
"async": False,
|
||||||
"filetype": suffix,
|
"filetype": suffix,
|
||||||
"key": f"cista_{file_path.stat().st_mtime_ns}",
|
"key": f"cista_{(await asyncio.to_thread(file_path.stat)).st_mtime_ns}",
|
||||||
"outputtype": "png",
|
"outputtype": "png",
|
||||||
"title": file_path.name,
|
"title": file_path.name,
|
||||||
"url": doc_url,
|
"url": doc_url,
|
||||||
@@ -149,16 +284,15 @@ def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
|||||||
payload["token"] = token
|
payload["token"] = token
|
||||||
headers["Authorization"] = token
|
headers["Authorization"] = token
|
||||||
|
|
||||||
req = urllib.request.Request( # noqa: S310
|
|
||||||
convert_url,
|
|
||||||
data=json.dumps(payload).encode(),
|
|
||||||
headers=headers,
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
t_start = perf_counter()
|
t_start = perf_counter()
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
response = await client.post(
|
||||||
body = resp.read()
|
convert_url,
|
||||||
|
content=json.dumps(payload).encode(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=request_timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.content
|
||||||
t_end = perf_counter()
|
t_end = perf_counter()
|
||||||
|
|
||||||
# Parse XML response
|
# Parse XML response
|
||||||
@@ -178,7 +312,8 @@ def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
|||||||
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
||||||
|
|
||||||
# Download converted PNG
|
# Download converted PNG
|
||||||
with urllib.request.urlopen(file_url, timeout=timeout) as png_resp: # noqa: S310
|
png_response = await client.get(file_url, timeout=request_timeout)
|
||||||
return png_resp.read()
|
png_response.raise_for_status()
|
||||||
|
return png_response.content
|
||||||
finally:
|
finally:
|
||||||
httpd.shutdown()
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
|
|||||||
+269
-334
@@ -1,7 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import gc
|
|
||||||
import io
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -10,41 +8,28 @@ import urllib.parse
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing import cpu_count
|
from multiprocessing import cpu_count
|
||||||
from pathlib import PurePosixPath
|
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 msgspec
|
||||||
import numpy as np
|
|
||||||
import pyvips
|
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
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, sharefs
|
from cista import auth, config, onlyoffice, sharefs, watching
|
||||||
from cista.preview_worker import PreviewRequest, PreviewResponse
|
from cista.fileio import fuid
|
||||||
|
from cista.preview_worker import (
|
||||||
|
DOC_PREVIEW_SUFFIXES,
|
||||||
|
OFFICE_PREVIEW_SUFFIXES,
|
||||||
|
PreviewRequest,
|
||||||
|
PreviewResponse,
|
||||||
|
)
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
# OnlyOffice integration is loaded lazily; availability is checked at runtime.
|
|
||||||
_onlyoffice = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice():
|
|
||||||
global _onlyoffice
|
|
||||||
if _onlyoffice is None:
|
|
||||||
try:
|
|
||||||
from cista import onlyoffice as oo
|
|
||||||
|
|
||||||
_onlyoffice = oo
|
|
||||||
except Exception:
|
|
||||||
_onlyoffice = False
|
|
||||||
return _onlyoffice
|
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
@@ -112,24 +97,30 @@ class _PreviewWorker:
|
|||||||
def __init__(self, proc: asyncio.subprocess.Process):
|
def __init__(self, proc: asyncio.subprocess.Process):
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
|
|
||||||
async def request(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
async def request(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
quality: int,
|
||||||
|
maxsize: int,
|
||||||
|
maxzoom: float,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
if self.proc.returncode is not None:
|
if self.proc.returncode is not None:
|
||||||
raise WorkerProtocolError("worker already exited")
|
raise WorkerProtocolError("worker already exited")
|
||||||
if self.proc.stdin is None or self.proc.stdout is None:
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
raise WorkerProtocolError("worker streams not available")
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
line = (
|
meta = msgspec.json.encode(
|
||||||
msgspec.json.encode(
|
PreviewRequest(
|
||||||
PreviewRequest(
|
path=str(filepath),
|
||||||
path=str(filepath),
|
quality=quality,
|
||||||
quality=quality,
|
maxsize=maxsize,
|
||||||
maxsize=maxsize,
|
maxzoom=maxzoom,
|
||||||
maxzoom=maxzoom,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
+ b"\n"
|
|
||||||
)
|
)
|
||||||
self.proc.stdin.write(line)
|
payload = data or b""
|
||||||
|
packet = struct.pack("<II", len(meta), len(payload)) + meta + payload
|
||||||
|
self.proc.stdin.write(packet)
|
||||||
await self.proc.stdin.drain()
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||||
@@ -172,6 +163,14 @@ class _PreviewWorkerPool:
|
|||||||
self._seq = 0
|
self._seq = 0
|
||||||
self._closed = False
|
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:
|
async def _spawn_worker(self) -> _PreviewWorker:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
sys.executable,
|
sys.executable,
|
||||||
@@ -179,10 +178,35 @@ class _PreviewWorkerPool:
|
|||||||
"cista.preview_worker",
|
"cista.preview_worker",
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
_active_procs.add(proc)
|
_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)
|
return _PreviewWorker(proc)
|
||||||
|
|
||||||
async def _add_worker(self) -> None:
|
async def _add_worker(self) -> None:
|
||||||
@@ -210,7 +234,25 @@ class _PreviewWorkerPool:
|
|||||||
if future.cancelled():
|
if future.cancelled():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
worker = await self._idle.get()
|
try:
|
||||||
|
worker = await asyncio.wait_for(
|
||||||
|
self._idle.get(), timeout=PREVIEW_TIMEOUT
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker unavailable (%ds) for %s",
|
||||||
|
int(PREVIEW_TIMEOUT),
|
||||||
|
args[0].name,
|
||||||
|
)
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
args[0].name,
|
||||||
|
backend=_expected_preview_backend(args[0]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
filepath = args[0]
|
filepath = args[0]
|
||||||
replace = False
|
replace = False
|
||||||
try:
|
try:
|
||||||
@@ -222,11 +264,13 @@ class _PreviewWorkerPool:
|
|||||||
future.set_result((out, resp))
|
future.set_result((out, resp))
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
replace = True
|
replace = True
|
||||||
logger.warning(
|
|
||||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
|
||||||
)
|
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(PreviewTimeoutError(filepath.name))
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
filepath.name,
|
||||||
|
backend=_expected_preview_backend(filepath),
|
||||||
|
)
|
||||||
|
)
|
||||||
except WorkerChecksumError:
|
except WorkerChecksumError:
|
||||||
replace = True
|
replace = True
|
||||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||||
@@ -256,6 +300,15 @@ class _PreviewWorkerPool:
|
|||||||
f"worker protocol failure for {filepath.name}: {e}"
|
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:
|
finally:
|
||||||
if replace:
|
if replace:
|
||||||
await self._replace_worker(worker)
|
await self._replace_worker(worker)
|
||||||
@@ -265,12 +318,23 @@ class _PreviewWorkerPool:
|
|||||||
await self._replace_worker(worker)
|
await self._replace_worker(worker)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
for _ in range(self.size):
|
workers = await asyncio.gather(
|
||||||
await self._add_worker()
|
*(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):
|
for _ in range(self.size):
|
||||||
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
||||||
|
|
||||||
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
async def run(
|
||||||
|
self,
|
||||||
|
filepath,
|
||||||
|
quality: int,
|
||||||
|
maxsize: int,
|
||||||
|
maxzoom: float,
|
||||||
|
data: bytes | None = None,
|
||||||
|
):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise PreviewError("preview worker pool closed")
|
raise PreviewError("preview worker pool closed")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -281,7 +345,7 @@ class _PreviewWorkerPool:
|
|||||||
_preview_job_priority(filepath),
|
_preview_job_priority(filepath),
|
||||||
self._seq,
|
self._seq,
|
||||||
future,
|
future,
|
||||||
(filepath, quality, maxsize, maxzoom),
|
(filepath, quality, maxsize, maxzoom, data),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return await future
|
return await future
|
||||||
@@ -354,6 +418,10 @@ async def verify_preview(request):
|
|||||||
class PreviewTimeoutError(Exception):
|
class PreviewTimeoutError(Exception):
|
||||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, backend: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
class PreviewError(Exception):
|
class PreviewError(Exception):
|
||||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
@@ -370,58 +438,110 @@ class PreviewError(Exception):
|
|||||||
self.backend = backend
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
# Max concurrent OnlyOffice conversion requests. OO has its own queue;
|
||||||
|
# we must not flood it. This is intentionally small.
|
||||||
|
OO_MAX_CONCURRENT = PREVIEW_WORKERS
|
||||||
|
|
||||||
|
|
||||||
|
class OOConversionManager:
|
||||||
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
||||||
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
|
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
||||||
|
stat = await asyncio.to_thread(filepath.stat)
|
||||||
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._in_flight:
|
||||||
|
future = self._in_flight[key]
|
||||||
|
else:
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._in_flight[key] = future
|
||||||
|
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _do_convert(
|
||||||
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with self._semaphore:
|
||||||
|
png_bytes = await onlyoffice.convert_to_png_async(
|
||||||
|
filepath, request_timeout=5.0
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
else:
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(png_bytes)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_manager: OOConversionManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_oo_manager() -> OOConversionManager:
|
||||||
|
"""Return the singleton OOConversionManager."""
|
||||||
|
global _oo_manager
|
||||||
|
if _oo_manager is None:
|
||||||
|
_oo_manager = OOConversionManager(max_concurrent=OO_MAX_CONCURRENT)
|
||||||
|
return _oo_manager
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_office_preview(
|
||||||
|
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
|
||||||
|
manager = get_oo_manager()
|
||||||
|
t_oo_start = perf_counter()
|
||||||
|
png_bytes = await manager.convert(filepath)
|
||||||
|
t_oo_end = perf_counter()
|
||||||
|
|
||||||
|
img, resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom, data=png_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp is not None:
|
||||||
|
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
|
||||||
|
if resp.timings:
|
||||||
|
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
||||||
|
return img, resp
|
||||||
|
|
||||||
|
|
||||||
async def _run_preview_process(
|
async def _run_preview_process(
|
||||||
filepath, quality: int, maxsize: int, maxzoom: float
|
filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
) -> tuple[bytes | None, PreviewResponse | None]:
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
"""Run preview request in a persistent worker process."""
|
"""Run preview request in a persistent worker process."""
|
||||||
await start_preview_workers()
|
await start_preview_workers()
|
||||||
if _preview_pool is None:
|
if _preview_pool is None:
|
||||||
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
||||||
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
||||||
|
|
||||||
|
|
||||||
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
def _onlyoffice_error_short_text(detail: str) -> str:
|
||||||
|
if detail.startswith("OnlyOffice conversion error:"):
|
||||||
OFFICE_PREVIEW_SUFFIXES = {
|
code = detail.rsplit(":", 1)[-1].strip()
|
||||||
".doc",
|
return {
|
||||||
".dot",
|
"-8": "onlyoffice jwt error",
|
||||||
".docx",
|
"-4": "onlyoffice input error",
|
||||||
".docm",
|
"-2": "onlyoffice timeout error",
|
||||||
".dotx",
|
"-1": "onlyoffice unknown error",
|
||||||
".dotm",
|
}.get(code, f"onlyoffice {code} error")
|
||||||
".rtf",
|
if "OnlyOffice response did not contain FileUrl" in detail:
|
||||||
".odt",
|
return "onlyoffice no-fileurl error"
|
||||||
".ott",
|
return "onlyoffice error"
|
||||||
".txt",
|
|
||||||
".md",
|
|
||||||
".mhtml",
|
|
||||||
".mht",
|
|
||||||
".html",
|
|
||||||
".htm",
|
|
||||||
".xml",
|
|
||||||
".wps",
|
|
||||||
".wri",
|
|
||||||
# Spreadsheets
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".xlsm",
|
|
||||||
".xlsb",
|
|
||||||
".xltx",
|
|
||||||
".xltm",
|
|
||||||
".ods",
|
|
||||||
".ots",
|
|
||||||
".csv",
|
|
||||||
# Presentations
|
|
||||||
".ppt",
|
|
||||||
".pptx",
|
|
||||||
".pptm",
|
|
||||||
".pps",
|
|
||||||
".ppsx",
|
|
||||||
".pot",
|
|
||||||
".potx",
|
|
||||||
".odp",
|
|
||||||
".otp",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_job_priority(path) -> int:
|
def _preview_job_priority(path) -> int:
|
||||||
@@ -442,6 +562,21 @@ def _preview_job_priority(path) -> int:
|
|||||||
return 4
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_preview_backend(path: Path) -> str:
|
||||||
|
"""Best-effort backend label used for timeout/access logging."""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return "onlyoffice"
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return "pdf"
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
return "video"
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
return "pyvips"
|
||||||
|
return "preview"
|
||||||
|
|
||||||
|
|
||||||
def is_previewable_path(path) -> bool:
|
def is_previewable_path(path) -> bool:
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
@@ -492,14 +627,39 @@ async def preview(req, path):
|
|||||||
|
|
||||||
# Generate preview
|
# Generate preview
|
||||||
try:
|
try:
|
||||||
img, preview_resp = await _run_preview_process(
|
if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES:
|
||||||
filepath, quality, maxsize, maxzoom
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_generate_office_preview(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_run_preview_process(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
|
||||||
|
return empty(503)
|
||||||
|
except PreviewTimeoutError as e:
|
||||||
|
req.ctx.log_extra = (
|
||||||
|
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
|
||||||
)
|
)
|
||||||
except PreviewTimeoutError:
|
return empty(503)
|
||||||
return empty(504)
|
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:
|
except PreviewError as e:
|
||||||
if e.backend:
|
if e.backend:
|
||||||
req.ctx._log_extra = e.backend
|
req.ctx.log_extra = e.backend
|
||||||
detail = str(e)
|
detail = str(e)
|
||||||
if detail == "preview worker error" and e.stderr:
|
if detail == "preview worker error" and e.stderr:
|
||||||
captured = e.stderr.strip()
|
captured = e.stderr.strip()
|
||||||
@@ -507,18 +667,30 @@ async def preview(req, path):
|
|||||||
detail = captured.splitlines()[0]
|
detail = captured.splitlines()[0]
|
||||||
logger.error("%s preview: %s", filepath, detail)
|
logger.error("%s preview: %s", filepath, detail)
|
||||||
return empty(422)
|
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 and preview_resp.backend:
|
||||||
if preview_resp.timings:
|
if preview_resp.timings:
|
||||||
timing_detail = "/".join(
|
timing_detail = "/".join(
|
||||||
str(round(value)) for value in preview_resp.timings
|
str(round(value)) for value in preview_resp.timings
|
||||||
)
|
)
|
||||||
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
req.ctx.log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||||
else:
|
else:
|
||||||
req.ctx._log_extra = preview_resp.backend
|
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)
|
||||||
|
|
||||||
|
# Store aspect ratio if the worker returned dimensions
|
||||||
|
if preview_resp and preview_resp.width and preview_resp.height:
|
||||||
|
ar = round(preview_resp.height / preview_resp.width, 2)
|
||||||
|
fuid_str = fuid(stat)
|
||||||
|
watching.notify_ar(fuid_str, ar)
|
||||||
|
|
||||||
# Build headers and cache the full response
|
# Build headers and cache the full response
|
||||||
preview_mime = (
|
preview_mime = (
|
||||||
preview_resp.mime
|
preview_resp.mime
|
||||||
@@ -537,240 +709,3 @@ async def preview(req, path):
|
|||||||
_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):
|
|
||||||
backend = "unknown"
|
|
||||||
try:
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if suffix in DOC_PREVIEW_SUFFIXES:
|
|
||||||
backend = "pdf"
|
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
|
||||||
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
|
||||||
backend = "onlyoffice"
|
|
||||||
return process_office(
|
|
||||||
path, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
|
||||||
)
|
|
||||||
mime_type, _ = mimetypes.guess_type(path.name)
|
|
||||||
if mime_type and mime_type.startswith("video/"):
|
|
||||||
backend = "video"
|
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
|
||||||
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:
|
|
||||||
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 process_image_pyvips(path, *, maxsize, quality):
|
|
||||||
t_start = perf_counter()
|
|
||||||
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
|
||||||
img = img.autorot()
|
|
||||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
|
||||||
if scale < 1.0:
|
|
||||||
img = img.resize(scale)
|
|
||||||
ret = img.write_to_buffer(
|
|
||||||
".avif",
|
|
||||||
Q=quality,
|
|
||||||
effort=AVIF_FAST_EFFORT,
|
|
||||||
strip=True,
|
|
||||||
)
|
|
||||||
t_end = perf_counter()
|
|
||||||
|
|
||||||
return ret, 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_office(path, *, quality, maxsize, maxzoom):
|
|
||||||
t_load_start = perf_counter()
|
|
||||||
oo = _get_onlyoffice()
|
|
||||||
if oo is False:
|
|
||||||
raise RuntimeError("OnlyOffice is not installed")
|
|
||||||
if not oo.is_available():
|
|
||||||
raise RuntimeError("OnlyOffice Document Server is not reachable")
|
|
||||||
png_bytes = oo.convert_to_png(path)
|
|
||||||
t_load_end = perf_counter()
|
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
|
||||||
img = pyvips.Image.new_from_buffer(png_bytes, "")
|
|
||||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
|
||||||
if scale < 1.0:
|
|
||||||
img = img.resize(scale)
|
|
||||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
|
|
||||||
backend = "onlyoffice+pyvips"
|
|
||||||
t_save_end = perf_counter()
|
|
||||||
|
|
||||||
return ret, PreviewResponse(
|
|
||||||
ok=True,
|
|
||||||
mime="image/avif",
|
|
||||||
backend=backend,
|
|
||||||
timings=[
|
|
||||||
round((t_load_end - t_load_start) * 1000, 1),
|
|
||||||
round((t_save_end - t_save_start) * 1000, 1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not isinstance(ostream, av.VideoStream):
|
|
||||||
raise PreviewError("failed to initialize AV1 video stream")
|
|
||||||
ostream.width = frame.width
|
|
||||||
ostream.height = frame.height
|
|
||||||
ostream.pix_fmt = frame.format.name
|
|
||||||
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
|
|
||||||
|
|||||||
+456
-11
@@ -1,24 +1,86 @@
|
|||||||
"""Preview generation worker subprocess.
|
"""Preview generation worker subprocess and synchronous preview engine.
|
||||||
|
|
||||||
Two modes are supported:
|
Two modes are supported:
|
||||||
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||||
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
|
2) Long-lived mode: read framed requests from stdin and write framed responses.
|
||||||
|
|
||||||
Framed response format:
|
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)
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import gc
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import av
|
||||||
|
import fitz # PyMuPDF
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import numpy as np
|
||||||
|
import pyvips
|
||||||
from blake3 import blake3
|
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):
|
class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
||||||
path: str
|
path: str
|
||||||
@@ -34,12 +96,38 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
|||||||
timings: list[float] | None = None
|
timings: list[float] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
stderr: str | None = None
|
stderr: str | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
_enc = msgspec.json.Encoder()
|
_enc = msgspec.json.Encoder()
|
||||||
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
_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:
|
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
meta_bytes = _enc.encode(resp)
|
meta_bytes = _enc.encode(resp)
|
||||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||||
@@ -49,13 +137,358 @@ def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
|||||||
sys.stdout.buffer.flush()
|
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))
|
||||||
|
img = img.autorot()
|
||||||
|
except pyvips.error.Error:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return img.width, img.height
|
||||||
|
|
||||||
|
|
||||||
|
def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||||
|
"""Convert any image to AVIF using ffmpeg CLI.
|
||||||
|
|
||||||
|
ffmpeg handles HEIC tile assembly, EXIF rotation, HDR metadata and
|
||||||
|
ICC profile embedding automatically.
|
||||||
|
"""
|
||||||
|
dims = _get_image_dimensions(path)
|
||||||
|
crf = int(63 * (1 - quality / 100) ** 2)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".avif", delete=False) as tmp_f:
|
||||||
|
tmp_path = tmp_f.name
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-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:
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
shell_cmd = shlex.join(cmd)
|
||||||
|
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||||
|
if stderr:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
|
||||||
|
) from e
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
|
||||||
|
) from e
|
||||||
|
with Path(tmp_path).open("rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_pyvips(path, *, maxsize, quality):
|
||||||
|
t_start = perf_counter()
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
|
||||||
|
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||||
|
# skip pyvips entirely.
|
||||||
|
if suffix in (".heic", ".heif"):
|
||||||
|
heic_dims = _get_image_dimensions(path)
|
||||||
|
width, height = heic_dims or (None, None)
|
||||||
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
|
t_end = perf_counter()
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="ffmpeg",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other image formats: pyvips first, ffmpeg fallback.
|
||||||
|
load_opts = {"access": "sequential"}
|
||||||
|
orig_w = orig_h = None
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||||
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
backend = "pyvips"
|
||||||
|
except pyvips.error.Error:
|
||||||
|
orig_w, orig_h = None, None
|
||||||
|
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)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||||
|
_ = maxzoom
|
||||||
|
t_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_buffer(data, "")
|
||||||
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="pyvips",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
width=round(w),
|
||||||
|
height=round(h),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(path, *, maxsize, quality):
|
||||||
|
frame = None
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
istream = ostream = icc = occ = frame = None
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
||||||
|
t_load_end = t_load_start
|
||||||
|
t_save_start = t_load_start
|
||||||
|
t_save_end = t_load_start
|
||||||
|
with (
|
||||||
|
av.open(
|
||||||
|
str(path),
|
||||||
|
options={
|
||||||
|
"analyzeduration": "1000000", # 1 second (in microseconds)
|
||||||
|
"fflags": "fastseek",
|
||||||
|
},
|
||||||
|
) as icontainer,
|
||||||
|
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||||
|
):
|
||||||
|
istream = icontainer.streams.video[0]
|
||||||
|
istream.codec_context.skip_frame = "NONKEY"
|
||||||
|
icontainer.seek((icontainer.duration or 0) // 8)
|
||||||
|
for frame in icontainer.decode(istream):
|
||||||
|
if frame.dts is not None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
|
# Resize frame to thumbnail size
|
||||||
|
# Capture display dimensions before resize (accounting for rotation)
|
||||||
|
disp_w = frame.width
|
||||||
|
disp_h = frame.height
|
||||||
|
if frame.rotation in (90, 270):
|
||||||
|
disp_w, disp_h = disp_h, disp_w
|
||||||
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
|
new_width = int(frame.width * scale_factor)
|
||||||
|
new_height = int(frame.height * scale_factor)
|
||||||
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
|
# Apply EXIF rotation if present
|
||||||
|
if frame.rotation:
|
||||||
|
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||||
|
# np.rot90 rotates counter-clockwise, so we negate k
|
||||||
|
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||||
|
if k == 2:
|
||||||
|
# 180° rotation can be done in YUV420p, preserving HDR
|
||||||
|
try:
|
||||||
|
fplanes = frame.to_ndarray()
|
||||||
|
# Split into Y, U, V planes of proper dimensions
|
||||||
|
planes = [
|
||||||
|
fplanes[: frame.height],
|
||||||
|
fplanes[
|
||||||
|
frame.height : frame.height + frame.height // 4
|
||||||
|
].reshape(frame.height // 2, frame.width // 2),
|
||||||
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
|
frame.height // 2, frame.width // 2
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Rotate each plane by 180°
|
||||||
|
planes = [np.rot90(p, 2) for p in planes]
|
||||||
|
# Restore PyAV format
|
||||||
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
|
-1, planes[0].shape[1]
|
||||||
|
)
|
||||||
|
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||||
|
del planes, fplanes
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error rotating video frame by 180°")
|
||||||
|
elif k in (1, 3):
|
||||||
|
# 90° or 270° rotation requires RGB conversion (loses HDR)
|
||||||
|
try:
|
||||||
|
rgb = frame.to_ndarray(format="rgb24")
|
||||||
|
rgb = np.rot90(rgb, k)
|
||||||
|
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
|
||||||
|
frame = frame.reformat(
|
||||||
|
format="yuv420p"
|
||||||
|
) # Convert back for encoding
|
||||||
|
del rgb
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Error rotating video frame by %s°", frame.rotation
|
||||||
|
)
|
||||||
|
|
||||||
|
# libsvtav1 rejects full-range JPEG-style YUV pixel formats such as
|
||||||
|
# yuvj420p, so normalize them before opening the encoder.
|
||||||
|
if frame.format.name.startswith("yuvj"):
|
||||||
|
frame = frame.reformat(format="yuv420p")
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
||||||
|
ostream = ocontainer.add_stream(
|
||||||
|
"av1",
|
||||||
|
options={
|
||||||
|
"crf": crf,
|
||||||
|
"usage": "realtime",
|
||||||
|
"cpu-used": "8",
|
||||||
|
"threads": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(ostream, av.VideoStream):
|
||||||
|
raise TypeError("failed to initialize AV1 video stream")
|
||||||
|
ostream.width = frame.width
|
||||||
|
ostream.height = frame.height
|
||||||
|
ostream.pix_fmt = frame.format.name
|
||||||
|
icc = istream.codec_context
|
||||||
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
# Copy HDR metadata from input video stream
|
||||||
|
occ.color_primaries = icc.color_primaries
|
||||||
|
occ.color_trc = icc.color_trc
|
||||||
|
occ.colorspace = icc.colorspace
|
||||||
|
occ.color_range = icc.color_range
|
||||||
|
|
||||||
|
ocontainer.mux(ostream.encode(frame))
|
||||||
|
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
# Capture result before cleanup
|
||||||
|
ret = imgdata.getvalue()
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="video",
|
||||||
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
|
width=disp_w,
|
||||||
|
height=disp_h,
|
||||||
|
)
|
||||||
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
|
gc.collect()
|
||||||
|
return ret, resp
|
||||||
|
|
||||||
|
|
||||||
def _run_once() -> None:
|
def _run_once() -> None:
|
||||||
if len(sys.argv) != 5:
|
if len(sys.argv) != 5:
|
||||||
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from cista.preview import dispatch
|
|
||||||
|
|
||||||
path = Path(sys.argv[1])
|
path = Path(sys.argv[1])
|
||||||
quality = int(sys.argv[2])
|
quality = int(sys.argv[2])
|
||||||
maxsize = int(sys.argv[3])
|
maxsize = int(sys.argv[3])
|
||||||
@@ -67,21 +500,19 @@ def _run_once() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _run_loop() -> None:
|
def _run_loop() -> None:
|
||||||
from cista.preview import dispatch
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
line = sys.stdin.buffer.readline()
|
result = _read_request()
|
||||||
if not line:
|
if result is None:
|
||||||
return
|
return
|
||||||
|
req, data = result
|
||||||
stderr_capture = io.StringIO()
|
stderr_capture = io.StringIO()
|
||||||
handler = logging.StreamHandler(stderr_capture)
|
handler = logging.StreamHandler(stderr_capture)
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
root_logger.addHandler(handler)
|
root_logger.addHandler(handler)
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stderr(stderr_capture):
|
with contextlib.redirect_stderr(stderr_capture):
|
||||||
req = _dec_req.decode(line)
|
|
||||||
result, resp = dispatch(
|
result, resp = dispatch(
|
||||||
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
captured = stderr_capture.getvalue().strip()
|
captured = stderr_capture.getvalue().strip()
|
||||||
@@ -94,6 +525,7 @@ def _run_loop() -> None:
|
|||||||
)
|
)
|
||||||
_write_response(resp, result or b"")
|
_write_response(resp, result or b"")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("Preview worker error for %s", req.path)
|
||||||
captured = stderr_capture.getvalue().strip()
|
captured = stderr_capture.getvalue().strip()
|
||||||
_write_response(
|
_write_response(
|
||||||
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
||||||
@@ -106,9 +538,22 @@ def _run_loop() -> None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
# Configure all log output to stderr before any imports that may emit logs.
|
# Configure all log output to stderr before any imports that may emit logs.
|
||||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
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:
|
if len(sys.argv) > 1:
|
||||||
_run_once()
|
_run_once()
|
||||||
return
|
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()
|
_run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
|
|||||||
## Directory listings
|
## Directory listings
|
||||||
|
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
|
||||||
level: int
|
level: int
|
||||||
name: str
|
name: str
|
||||||
key: str
|
key: str
|
||||||
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
size: int
|
size: int
|
||||||
allocated: int
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
ar: float | None = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key or "FileEntry()"
|
return self.key or "FileEntry()"
|
||||||
|
|||||||
+55
-11
@@ -1,12 +1,49 @@
|
|||||||
"""Custom access logging middleware for Sanic."""
|
"""Custom access logging middleware for Sanic."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class ReentrantSafeStreamHandler(logging.StreamHandler):
|
||||||
|
"""Stream handler that degrades gracefully on signal-time reentrant writes.
|
||||||
|
|
||||||
|
Python's buffered text streams are not reentrant. If a signal handler logs
|
||||||
|
while another log write is in progress, StreamHandler.emit can raise:
|
||||||
|
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
|
||||||
|
|
||||||
|
Instead of letting logging emit a long "--- Logging error ---" traceback,
|
||||||
|
we fall back to a best-effort os.write to the same file descriptor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
msg = ""
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
stream = self.stream
|
||||||
|
stream.write(msg + self.terminator)
|
||||||
|
self.flush()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "reentrant call inside" not in str(exc):
|
||||||
|
self.handleError(record)
|
||||||
|
return
|
||||||
|
stream = self.stream
|
||||||
|
fd = stream.fileno()
|
||||||
|
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||||
|
data = (msg + self.terminator).encode(encoding, errors="replace")
|
||||||
|
os.write(fd, data)
|
||||||
|
except RecursionError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
_RESET = "\033[0m"
|
_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)
|
||||||
@@ -96,10 +133,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:
|
||||||
@@ -233,7 +271,7 @@ def log_ws_close(
|
|||||||
|
|
||||||
def configure_access_logging() -> None:
|
def configure_access_logging() -> None:
|
||||||
"""Configure the cista.access logger to output to stderr."""
|
"""Configure the cista.access logger to output to stderr."""
|
||||||
handler = logging.StreamHandler(sys.stderr)
|
handler = ReentrantSafeStreamHandler(sys.stderr)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -242,20 +280,24 @@ def configure_access_logging() -> None:
|
|||||||
|
|
||||||
_LEVEL_EMOJI = {
|
_LEVEL_EMOJI = {
|
||||||
logging.DEBUG: "🔍",
|
logging.DEBUG: "🔍",
|
||||||
logging.INFO: "i",
|
logging.INFO: "ℹ️", # noqa: RUF001
|
||||||
logging.WARNING: "⚠️",
|
logging.WARNING: "⚠️",
|
||||||
logging.ERROR: "🛑",
|
logging.ERROR: "🛑",
|
||||||
logging.CRITICAL: "🛑",
|
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):
|
class _EmojiFormatter(logging.Formatter):
|
||||||
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
|
return _format_level_prefix(record.levelno) + record.getMessage()
|
||||||
sep = " " if record.levelno in (logging.INFO, logging.WARNING) else " "
|
|
||||||
return f"{emoji}{sep}{record.getMessage()}"
|
|
||||||
|
|
||||||
|
|
||||||
def configure_main_logging() -> None:
|
def configure_main_logging() -> None:
|
||||||
@@ -264,8 +306,10 @@ def configure_main_logging() -> None:
|
|||||||
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||||
call Sanic makes during serve_single() / serve().
|
call Sanic makes during serve_single() / serve().
|
||||||
"""
|
"""
|
||||||
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
for handler_name in ("console", "error_console", "access_console"):
|
||||||
|
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
|
||||||
|
"cista.sanic_logging.ReentrantSafeStreamHandler"
|
||||||
|
)
|
||||||
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||||
"class": "cista.sanic_logging._EmojiFormatter",
|
"class": "cista.sanic_logging._EmojiFormatter",
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -4,14 +4,19 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
from sanic.worker.loader import AppLoader
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
|
from cista.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def load_app() -> Sanic:
|
||||||
|
"""Return the app instance for spawned Sanic worker/reloader processes."""
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def run(*, dev=False):
|
def run(*, dev=False):
|
||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
from .app import app
|
|
||||||
|
|
||||||
_url, opts = parse_listen(config.config.listen)
|
_url, opts = parse_listen(config.config.listen)
|
||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
@@ -30,12 +35,13 @@ def run(*, dev=False):
|
|||||||
access_log=False,
|
access_log=False,
|
||||||
) # type: ignore[call-arg]
|
) # type: ignore[call-arg]
|
||||||
if dev:
|
if dev:
|
||||||
Sanic.serve()
|
Sanic.serve(app_loader=AppLoader(factory=load_app))
|
||||||
else:
|
else:
|
||||||
Sanic.serve_single()
|
Sanic.serve_single()
|
||||||
|
|
||||||
|
|
||||||
def check_cert(certdir, domain):
|
def check_cert(certdir, domain):
|
||||||
|
_ = domain
|
||||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||||
return
|
return
|
||||||
# Certificate provisioning is external; files must exist before startup.
|
# Certificate provisioning is external; files must exist before startup.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+8
-1
@@ -36,7 +36,7 @@ def get(request):
|
|||||||
def create(request, res, username, **kwargs):
|
def create(request, res, username, **kwargs):
|
||||||
_purge_expired()
|
_purge_expired()
|
||||||
token = _token()
|
token = _token()
|
||||||
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
|
put(token, username, **kwargs)
|
||||||
secure = request.scheme == "https"
|
secure = request.scheme == "https"
|
||||||
res.cookies.add_cookie(
|
res.cookies.add_cookie(
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
@@ -49,10 +49,17 @@ def create(request, res, username, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def delete(request, res):
|
def delete(request, res):
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if token is not None:
|
||||||
|
_sessions.pop(token, None)
|
||||||
secure = request.scheme == "https"
|
secure = request.scheme == "https"
|
||||||
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
|
res.cookies.delete_cookie(SESSION_COOKIE_NAME, host_prefix=secure)
|
||||||
|
|
||||||
|
|
||||||
|
def put(token: str, username: str, **kwargs) -> None:
|
||||||
|
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
|
||||||
|
|
||||||
|
|
||||||
def flash(res, message: str | None):
|
def flash(res, message: str | None):
|
||||||
if message is None:
|
if message is None:
|
||||||
res.cookies.delete_cookie("message")
|
res.cookies.delete_cookie("message")
|
||||||
|
|||||||
+5
-2
@@ -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()
|
||||||
@@ -257,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()])
|
||||||
|
|
||||||
@@ -348,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -362,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import websockets.exceptions
|
||||||
from sanic import errorpages
|
from sanic import errorpages
|
||||||
from sanic.exceptions import SanicException
|
from sanic.exceptions import SanicException
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
@@ -60,13 +61,19 @@ 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
|
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)
|
||||||
|
except (
|
||||||
|
websockets.exceptions.ConnectionClosedOK,
|
||||||
|
websockets.exceptions.ConnectionClosedError,
|
||||||
|
):
|
||||||
|
# Normal websocket closure - already logged in access log
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
|
|||||||
@@ -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,7 +33,7 @@ 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()
|
||||||
|
|
||||||
@@ -87,9 +87,9 @@ class SyncRequest:
|
|||||||
def set_result(self, value):
|
def set_result(self, value):
|
||||||
"""Set result value; mark as done."""
|
"""Set result value; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, value))
|
self.alink.await_sync(set_result(self.future, value))
|
||||||
|
|
||||||
def set_exception(self, exc):
|
def set_exception(self, exc):
|
||||||
"""Set exception; mark as done."""
|
"""Set exception; mark as done."""
|
||||||
self.done = True
|
self.done = True
|
||||||
self.alink._await(set_result(self.future, exception=exc))
|
self.alink.await_sync(set_result(self.future, exception=exc))
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_FREE_BYTES = 128 * 1024 * 1024
|
||||||
|
_CHECK_CACHE_TTL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientStorageError(Exception):
|
||||||
|
"""Raised when there is not enough disk space for an operation."""
|
||||||
|
|
||||||
|
|
||||||
|
_cache: dict[Path, tuple[float, int]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check_free_space(path: Path) -> None:
|
||||||
|
"""Raise InsufficientStorageError if free space on the filesystem containing *path*
|
||||||
|
|
||||||
|
is below MIN_FREE_BYTES. Results are cached per directory for 1 second.
|
||||||
|
"""
|
||||||
|
check_path = path.parent if path.parent.exists() else path
|
||||||
|
check_path = check_path.resolve()
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
ts, free = _cache.get(check_path, (0, 0))
|
||||||
|
if now - ts < _CHECK_CACHE_TTL:
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
free = shutil.disk_usage(check_path).free
|
||||||
|
except OSError as e:
|
||||||
|
raise InsufficientStorageError(f"Cannot check disk usage: {e}") from e
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_cache[check_path] = (now, free)
|
||||||
|
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
@@ -0,0 +1,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))
|
||||||
+89
-11
@@ -17,6 +17,11 @@ from cista import config
|
|||||||
from cista.fileio import fuid
|
from cista.fileio import fuid
|
||||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inotify.adapters as inotify_adapters
|
||||||
|
except Exception:
|
||||||
|
inotify_adapters = None
|
||||||
|
|
||||||
# Platform-specific allocated size calculation
|
# Platform-specific allocated size calculation
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -30,6 +35,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 +46,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 +55,14 @@ pubsub = {}
|
|||||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatUpdateLoopError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _WatcherStoppingError(Exception):
|
||||||
|
"""Internal control-flow exception for quick watcher shutdown."""
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
@@ -147,6 +162,17 @@ 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()
|
||||||
|
|
||||||
|
# Thread-safe queue for AR updates from the preview worker
|
||||||
|
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
|
||||||
|
|
||||||
|
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
|
||||||
|
_ar_map: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_ar(fuid_key: str, ar: float) -> None:
|
||||||
|
"""Called from preview handler to update the AR for a file."""
|
||||||
|
_ar_queue.put_nowait((fuid_key, ar))
|
||||||
|
|
||||||
|
|
||||||
def notify_change(*paths: PurePosixPath | str):
|
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."""
|
||||||
@@ -179,14 +205,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"get_allocated_size failed for {path}")
|
logger.exception(f"get_allocated_size failed for {path}")
|
||||||
allocated = st.st_size if isfile else 0
|
allocated = st.st_size if isfile else 0
|
||||||
|
key = fuid(st)
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=key,
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
allocated=allocated,
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
|
ar=_ar_map.get(key) if isfile else None,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
return [entry]
|
return [entry]
|
||||||
@@ -195,7 +223,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
li = []
|
li = []
|
||||||
for f in path.iterdir():
|
for f in path.iterdir():
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
raise SystemExit("quit")
|
raise _WatcherStoppingError
|
||||||
if f.name.startswith("."):
|
if f.name.startswith("."):
|
||||||
continue # No dotfiles
|
continue # No dotfiles
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
@@ -207,7 +235,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
li.append((int(isfile), f.name, s))
|
li.append((int(isfile), f.name, s))
|
||||||
# Build the tree as a list of FileEntries
|
# Build the tree as a list of FileEntries
|
||||||
for [_, name, s] in humansorted(li):
|
for [_, name, s] in humansorted(li):
|
||||||
|
if stop_event.is_set():
|
||||||
|
raise _WatcherStoppingError
|
||||||
sub = walk(rel / name, stat=s)
|
sub = walk(rel / name, stat=s)
|
||||||
|
if not sub:
|
||||||
|
continue
|
||||||
child = sub[0]
|
child = sub[0]
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=entry.level,
|
level=entry.level,
|
||||||
@@ -244,6 +276,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:
|
||||||
@@ -300,7 +333,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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -636,13 +669,14 @@ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
|
|||||||
|
|
||||||
def watcher(loop):
|
def watcher(loop):
|
||||||
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
|
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
|
||||||
use_inotify = sys.platform == "linux"
|
use_inotify = sys.platform == "linux" and inotify_adapters is not None
|
||||||
inotify_tree = None
|
inotify_tree = None
|
||||||
modified_flags = frozenset()
|
modified_flags = frozenset()
|
||||||
|
|
||||||
if use_inotify:
|
if sys.platform == "linux" and inotify_adapters is None:
|
||||||
import inotify.adapters
|
logger.warning("inotify unavailable; falling back to periodic scanning")
|
||||||
|
|
||||||
|
if use_inotify:
|
||||||
modified_flags = frozenset(
|
modified_flags = frozenset(
|
||||||
(
|
(
|
||||||
"IN_CREATE",
|
"IN_CREATE",
|
||||||
@@ -657,12 +691,13 @@ def watcher(loop):
|
|||||||
|
|
||||||
while not stop_event.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
|
||||||
update_root(loop)
|
try:
|
||||||
|
update_root(loop)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
path_index = PathIndex(state.root[:])
|
path_index = PathIndex(state.root[:])
|
||||||
|
|
||||||
trefresh = time.monotonic() + 300.0
|
trefresh = time.monotonic() + 300.0
|
||||||
@@ -746,7 +781,10 @@ def watcher(loop):
|
|||||||
# Process each collapsed path
|
# Process each collapsed path
|
||||||
new_root = path_index.root
|
new_root = path_index.root
|
||||||
for path in collapsed:
|
for path in collapsed:
|
||||||
new_entries = walk(path)
|
try:
|
||||||
|
new_entries = walk(path)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
new_root = path_index.apply_update(path, new_entries)
|
new_root = path_index.apply_update(path, new_entries)
|
||||||
|
|
||||||
# Broadcast if changed
|
# Broadcast if changed
|
||||||
@@ -765,12 +803,51 @@ def watcher(loop):
|
|||||||
with state.lock:
|
with state.lock:
|
||||||
broadcast(update_msg, loop)
|
broadcast(update_msg, loop)
|
||||||
state.root = fresh
|
state.root = fresh
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Fallback failed; sending full root")
|
logger.exception("Fallback failed; sending full root")
|
||||||
with state.lock:
|
with state.lock:
|
||||||
broadcast(format_root(fresh), loop)
|
broadcast(format_root(fresh), loop)
|
||||||
state.root = fresh
|
state.root = fresh
|
||||||
|
|
||||||
|
# Drain AR updates from preview worker (immediate, no debounce)
|
||||||
|
ar_new_root: list[FileEntry] | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
fuid_key, ar = _ar_queue.get_nowait()
|
||||||
|
_ar_map[fuid_key] = ar
|
||||||
|
# Patch the matching entry in the current root
|
||||||
|
root_to_patch = (
|
||||||
|
ar_new_root if ar_new_root is not None else path_index.root
|
||||||
|
)
|
||||||
|
for i, entry in enumerate(root_to_patch):
|
||||||
|
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
|
||||||
|
if ar_new_root is None:
|
||||||
|
ar_new_root = root_to_patch[:]
|
||||||
|
ar_new_root[i] = FileEntry(
|
||||||
|
level=entry.level,
|
||||||
|
name=entry.name,
|
||||||
|
key=entry.key,
|
||||||
|
mtime=entry.mtime,
|
||||||
|
size=entry.size,
|
||||||
|
allocated=entry.allocated,
|
||||||
|
isfile=entry.isfile,
|
||||||
|
ar=ar,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
if ar_new_root is not None:
|
||||||
|
try:
|
||||||
|
update_msg = format_update(state.root, ar_new_root)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update_msg, loop)
|
||||||
|
state.root = ar_new_root
|
||||||
|
path_index = PathIndex(ar_new_root)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AR update broadcast failed")
|
||||||
|
|
||||||
# Collect events from websocket signals (non-blocking)
|
# Collect events from websocket signals (non-blocking)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -816,6 +893,7 @@ def start(app):
|
|||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
|
stop_event.clear()
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher,
|
target=watcher,
|
||||||
args=[app.loop],
|
args=[app.loop],
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -18,8 +18,11 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/language-data": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@imengyu/vue3-context-menu": "^1.5.3",
|
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.17.23",
|
||||||
|
|||||||
+149
-17
@@ -8,13 +8,36 @@
|
|||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
<UserTokensModal />
|
<UserTokensModal />
|
||||||
|
<AboutModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain
|
||||||
<BreadCrumb :path="path.pathList" primary />
|
ref="headerMain"
|
||||||
|
:path="path.pathList"
|
||||||
|
:query="path.query"
|
||||||
|
:editor-mode="path.isEditorPath"
|
||||||
|
/>
|
||||||
|
<BreadCrumb
|
||||||
|
:path="path.breadcrumbPathList"
|
||||||
|
:links="path.breadcrumbLinks"
|
||||||
|
primary
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="transition-wrapper">
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<Transition
|
||||||
|
:name="routeTransitionName"
|
||||||
|
@after-enter="store.transitionDirection = 'none'"
|
||||||
|
>
|
||||||
|
<div :key="routeViewKey" class="explorer-content">
|
||||||
|
<KeepAlive>
|
||||||
|
<component
|
||||||
|
:is="routeViewComponent"
|
||||||
|
:key="routeViewKey"
|
||||||
|
v-bind="routeViewProps"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
<SelectionToolbar :path="path.pathList" />
|
<SelectionToolbar :path="path.pathList" />
|
||||||
@@ -26,42 +49,108 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
|
||||||
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import AboutModal from './components/AboutModal.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 type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import UserTokensModal from './components/UserTokensModal.vue'
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
import ExplorerView from './views/ExplorerView.vue'
|
||||||
|
import TextEditorView from './views/TextEditorView.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
canonicalPath: string
|
||||||
|
isEditorPath: boolean
|
||||||
pathList: string[]
|
pathList: string[]
|
||||||
|
breadcrumbPathList: string[]
|
||||||
|
breadcrumbLinks?: string[]
|
||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const getDocByPath = (fullPath: string) =>
|
||||||
|
getDocuments().find(
|
||||||
|
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
|
||||||
|
)
|
||||||
|
|
||||||
const path: ComputedRef<Path> = computed(() => {
|
const path: ComputedRef<Path> = computed(() => {
|
||||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||||
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
const rawPath = p[0] ?? ''
|
||||||
|
const routePathList = rawPath.split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
|
const fullPath = routePathList.join('/')
|
||||||
|
// Access docVersion to make route mode reactive to tree updates
|
||||||
|
void store.docVersion
|
||||||
|
const doc = fullPath ? getDocByPath(fullPath) : null
|
||||||
|
const isEditorPath = !!(doc && !doc.dir && doc.text)
|
||||||
|
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
|
||||||
|
const canonicalPath = query
|
||||||
|
? `${rawPath}//${query}` // keep search URL shape untouched
|
||||||
|
: canonicalBase
|
||||||
|
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
|
||||||
|
const breadcrumbPathList = routePathList
|
||||||
|
const breadcrumbLinks = isEditorPath
|
||||||
|
? [
|
||||||
|
'/',
|
||||||
|
...routePathList
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
|
||||||
|
`/${fullPath}`
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
path: p[0] ?? '',
|
path: rawPath,
|
||||||
|
canonicalPath,
|
||||||
|
isEditorPath,
|
||||||
pathList,
|
pathList,
|
||||||
|
breadcrumbPathList,
|
||||||
|
breadcrumbLinks,
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
const routeTransitionName = computed(() => {
|
||||||
document.title =
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
store.server.name ||
|
return ''
|
||||||
'Cista Storage'
|
|
||||||
})
|
})
|
||||||
|
const routeViewComponent = computed(() =>
|
||||||
|
path.value.isEditorPath ? TextEditorView : ExplorerView
|
||||||
|
)
|
||||||
|
const routeViewKey = computed(() => {
|
||||||
|
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
|
||||||
|
})
|
||||||
|
const routeViewProps = computed(() =>
|
||||||
|
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => path.value.canonicalPath,
|
||||||
|
canonical => {
|
||||||
|
const current = decodeURIComponent(Router.currentRoute.value.path)
|
||||||
|
if (canonical && current !== canonical) {
|
||||||
|
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => path.value.path,
|
||||||
|
() => {
|
||||||
|
document.title =
|
||||||
|
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||||
|
store.server.name ||
|
||||||
|
'Cista Storage'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
onMounted(loadSession)
|
onMounted(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
onUnmounted(watchDisconnect)
|
onUnmounted(watchDisconnect)
|
||||||
@@ -80,7 +169,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const fileExplorer = store.fileExplorer as any
|
const fileExplorer = store.fileExplorer as any
|
||||||
if (!fileExplorer) return
|
if (!fileExplorer) return
|
||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const target = event.target as HTMLElement
|
||||||
|
const input =
|
||||||
|
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
|
|
||||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||||
@@ -95,6 +186,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
event.key === 'ArrowDown' ||
|
event.key === 'ArrowDown' ||
|
||||||
event.key === 'ArrowLeft' ||
|
event.key === 'ArrowLeft' ||
|
||||||
event.key === 'ArrowRight' ||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
(c && event.code === 'Space')
|
(c && event.code === 'Space')
|
||||||
) {
|
) {
|
||||||
if (!input) event.preventDefault()
|
if (!input) event.preventDefault()
|
||||||
@@ -104,6 +197,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
|
let paging = ''
|
||||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
// 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
|
||||||
@@ -115,17 +209,35 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
return // Let browser handle cursor movement
|
return // Let browser handle cursor movement
|
||||||
}
|
}
|
||||||
|
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||||
|
if (input && !searchInput) return
|
||||||
arrow = dir
|
arrow = dir
|
||||||
|
} else if (
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
|
event.key === 'Home' ||
|
||||||
|
event.key === 'End'
|
||||||
|
) {
|
||||||
|
if (input) return
|
||||||
|
paging = event.key
|
||||||
}
|
}
|
||||||
if (arrow) {
|
if (arrow) {
|
||||||
// Arrow key handling - fall through to bottom
|
// Arrow key handling - fall through to bottom
|
||||||
|
} else if (paging) {
|
||||||
|
// Paging/navigation key handling - fall through to bottom
|
||||||
}
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (
|
||||||
|
!path.value.isEditorPath &&
|
||||||
|
!input &&
|
||||||
|
!keyup &&
|
||||||
|
event.key === 'f' &&
|
||||||
|
(event.ctrlKey || event.metaKey)
|
||||||
|
) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||||
else if (!input && keyup && event.code === 'Slash') {
|
else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
|
||||||
// Record the actual character for display (varies by keyboard layout)
|
// Record the actual character for display (varies by keyboard layout)
|
||||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||||
store.prefs.searchHotkey = event.key
|
store.prefs.searchHotkey = event.key
|
||||||
@@ -136,7 +248,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
store.clearToast()
|
store.clearToast()
|
||||||
headerMain.value!.clearSearch(event)
|
// Keep rename and other non-search inputs isolated from search behavior.
|
||||||
|
if (input && !searchInput) return
|
||||||
|
if (!path.value.isEditorPath) {
|
||||||
|
headerMain.value!.clearSearch(event)
|
||||||
|
}
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
} else if (!input && keyup && event.key === 'Backspace') {
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
Router.back()
|
Router.back()
|
||||||
@@ -235,12 +351,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
|
||||||
|
switch (paging) {
|
||||||
|
case 'PageUp':
|
||||||
|
f = () => fileExplorer.pageUp?.(event)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
f = () => fileExplorer.pageDown?.(event)
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
f = () => fileExplorer.home?.(event)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
f = () => fileExplorer.end?.(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f) {
|
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,
|
const t0 = 200,
|
||||||
tr = event.altKey ? 20 : 100
|
tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
|
if (paging === 'Home' || paging === 'End') return
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = setInterval(f, tr)
|
timer = setInterval(f, tr)
|
||||||
}, t0 - tr)
|
}, t0 - tr)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
@@ -57,6 +57,68 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Directory navigation slide transitions */
|
||||||
|
.transition-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-content {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-backward-enter-active {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-enter-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-from {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-to {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-from {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-to {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
:root {
|
:root {
|
||||||
--primary-color: black;
|
--primary-color: black;
|
||||||
@@ -206,6 +268,8 @@ main {
|
|||||||
min-height: 0; /* Allow flex child to shrink below content size */
|
min-height: 0; /* Allow flex child to shrink below content size */
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
|
||||||
|
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 452 B |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 492 B |
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name="about" title="">
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-logo-pane">
|
||||||
|
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="about-details">
|
||||||
|
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
|
||||||
|
<p class="about-link">
|
||||||
|
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<div class="dialog-buttons about-actions">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input id="close" type="reset" value="Close" class="button" @click="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import logoUrl from '@/assets/logo-square.svg?url'
|
||||||
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const softwareVersion = computed(() => store.server.version || 'unknown')
|
||||||
|
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
|
||||||
|
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(#about.modal-dialog) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
width: min(35rem, 92vw);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin: -1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
margin: 0.65rem 0 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.about-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: min(24rem, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,32 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
<ModalDialog name="accessdenied" title="">
|
||||||
<div class="modal-dialog" id="accessdenied">
|
<div class="access-denied">
|
||||||
<div class="modal-content access-denied">
|
<p class="icon">⛔</p>
|
||||||
<p class="icon">⛔</p>
|
<p class="message">Access Denied</p>
|
||||||
<p class="message">Access Denied</p>
|
<button @click="reload" class="button">Reload</button>
|
||||||
<button @click="reload" class="button">Reload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
import { holdGlobalBackdrop } from 'paskia'
|
|
||||||
import { watchEffect } from 'vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep backdrop active when this dialog shows
|
|
||||||
watchEffect(() => {
|
|
||||||
if (store.dialog === 'accessdenied') {
|
|
||||||
holdGlobalBackdrop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@focus=focusCurrent
|
@focus=focusCurrent
|
||||||
tabindex=0
|
tabindex=0
|
||||||
>
|
>
|
||||||
<a href="#/"
|
<a :href="`/#${urlAt(0)}`"
|
||||||
:ref="el => setLinkRef(0, el)"
|
:ref="el => setLinkRef(0, el)"
|
||||||
class="home"
|
class="home"
|
||||||
:class="{ current: !!isCurrent(0) }"
|
:class="{ current: !!isCurrent(0) }"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
<template v-for="(location, index) in longest" :key="index">
|
<template v-for="(location, index) in longest" :key="index">
|
||||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
<a :href="`/#${urlAt(index + 1)}`"
|
||||||
:class="{ current: !!isCurrent(index + 1) }"
|
:class="{ current: !!isCurrent(index + 1) }"
|
||||||
:aria-current="isCurrent(index + 1)"
|
:aria-current="isCurrent(index + 1)"
|
||||||
@click.prevent="navigate(index + 1)"
|
@click.prevent="navigate(index + 1)"
|
||||||
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
|
links?: Array<string>
|
||||||
primary?: boolean
|
primary?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const longest = ref<Array<string>>([])
|
const longest = ref<Array<string>>([])
|
||||||
|
const longestLinks = ref<Array<string>>(['/'])
|
||||||
|
|
||||||
|
const defaultLinks = (segments: Array<string>) => [
|
||||||
|
'/',
|
||||||
|
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
|
||||||
|
]
|
||||||
|
|
||||||
const isCurrent = (index: number) =>
|
const isCurrent = (index: number) =>
|
||||||
index == props.path.length ? 'location' : undefined
|
index == props.path.length ? 'location' : undefined
|
||||||
@@ -77,16 +84,22 @@ const focusCurrent = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlAt = (index: number) => {
|
||||||
|
const explicit = longestLinks.value[index]
|
||||||
|
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
|
||||||
|
}
|
||||||
|
|
||||||
const navigate = (index: number) => {
|
const navigate = (index: number) => {
|
||||||
const link = links[index]
|
const link = links[index]
|
||||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
const url = urlAt(index)
|
||||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) {
|
if (isCurrent(index)) {
|
||||||
longest.value.splice(index)
|
longest.value.splice(index)
|
||||||
|
longestLinks.value.splice(index + 1)
|
||||||
router.push(u)
|
router.push(u)
|
||||||
}
|
}
|
||||||
// Moving along breadcrumbs doesn't create new history
|
// Moving along breadcrumbs doesn't create new history
|
||||||
@@ -102,20 +115,26 @@ const move = (dir: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
const currentLinks = props.links ?? defaultLinks(props.path)
|
||||||
const longcut = longest.value.slice(0, props.path.length)
|
const longcut = longest.value.slice(0, props.path.length)
|
||||||
const same = longcut.every((value, index) => value === props.path[index])
|
const same = longcut.every((value, index) => value === props.path[index])
|
||||||
// Navigated out of previous path, reset longest to current
|
// Navigated out of previous path, reset longest to current
|
||||||
if (!same) longest.value = props.path
|
if (!same) {
|
||||||
else if (props.path.length > longcut.length) {
|
longest.value = props.path
|
||||||
|
longestLinks.value = currentLinks
|
||||||
|
} else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
} else {
|
} else {
|
||||||
// Prune deleted folders from longest
|
// Prune deleted folders from longest
|
||||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||||
if (!exists(longest.value.slice(0, i + 1))) {
|
if (!exists(longest.value.slice(0, i + 1))) {
|
||||||
longest.value = longest.value.slice(0, i)
|
longest.value = longest.value.slice(0, i)
|
||||||
|
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
}
|
}
|
||||||
// If needed, focus primary navigation to new location
|
// If needed, focus primary navigation to new location
|
||||||
if (props.primary)
|
if (props.primary)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="showOtherCategory ? 'url(#otherGradient)' : freeColor" :stroke-width="ringWidth" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||||
@@ -38,12 +38,12 @@
|
|||||||
<g ref="labelsRef" class="pie-labels">
|
<g ref="labelsRef" class="pie-labels">
|
||||||
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||||
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
<text v-if="showOtherCategory" :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||||
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
<path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<text class="pie-label-sub" fill="#93e">
|
<text class="pie-label-sub" fill="#93e">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<text class="pie-label-sub" :fill="freeColor">
|
<text class="pie-label-sub" :fill="freeColor">
|
||||||
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||||
</text>
|
</text>
|
||||||
<text class="pie-label-sub" fill="#d9f">
|
<text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
|
||||||
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -98,18 +98,30 @@ const truncateLabel = (name: string, maxLen = 10): string => {
|
|||||||
return name.slice(0, maxLen - 1) + '…'
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const otherBytes = computed(() => Math.max(0, store.space.used - store.space.allocated))
|
||||||
|
const showOtherCategory = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
||||||
|
})
|
||||||
|
const freeSliceBytes = computed(() =>
|
||||||
|
showOtherCategory.value
|
||||||
|
? store.space.free
|
||||||
|
: Math.max(0, store.space.disk - store.space.allocated)
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate max label length based on angular gap to neighbor labels
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
const storageMaxLen = computed(() => {
|
const storageMaxLen = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return 10
|
if (!s.disk) return 10
|
||||||
// Sector spans in degrees
|
// Sector spans in degrees
|
||||||
const storageSpan = (s.allocated / s.disk) * 360
|
const storageSpan = (s.allocated / s.disk) * 360
|
||||||
const freeSpan = (s.free / s.disk) * 360
|
const freeSpan = (freeSliceBytes.value / s.disk) * 360
|
||||||
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
const otherSpan = (otherBytes.value / s.disk) * 360
|
||||||
// Angular gap from storage label midpoint to neighbor label midpoints
|
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||||
const gapToFree = (storageSpan + freeSpan) / 2
|
const gapToFree = (storageSpan + freeSpan) / 2
|
||||||
const gapToOther = (storageSpan + otherSpan) / 2
|
const minGap = showOtherCategory.value
|
||||||
const minGap = Math.min(gapToFree, gapToOther)
|
? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
|
||||||
|
: gapToFree
|
||||||
// Allow longer names when there's sufficient gap to both neighbors
|
// Allow longer names when there's sufficient gap to both neighbors
|
||||||
if (minGap > 70) return 18
|
if (minGap > 70) return 18
|
||||||
if (minGap > 55) return 14
|
if (minGap > 55) return 14
|
||||||
@@ -143,7 +155,7 @@ const pieStorageDash = computed(() => {
|
|||||||
const pieFreeDash = computed(() => {
|
const pieFreeDash = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const pieFreeOffsetVal = computed(() => {
|
const pieFreeOffsetVal = computed(() => {
|
||||||
@@ -179,8 +191,8 @@ const sectorInfo = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePct = s.allocated / s.disk
|
const storagePct = s.allocated / s.disk
|
||||||
const freePct = s.free / s.disk
|
const freePct = freeSliceBytes.value / s.disk
|
||||||
const otherPct = (s.used - s.allocated) / s.disk
|
const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
|
||||||
|
|
||||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
const freeStart = storagePct * 360
|
const freeStart = storagePct * 360
|
||||||
@@ -198,7 +210,7 @@ const sectorInfo = computed(() => {
|
|||||||
const rawAngles = computed(() => ({
|
const rawAngles = computed(() => ({
|
||||||
storage: sectorInfo.value.storage.angle,
|
storage: sectorInfo.value.storage.angle,
|
||||||
free: sectorInfo.value.free.angle,
|
free: sectorInfo.value.free.angle,
|
||||||
other: sectorInfo.value.other.angle
|
...(showOtherCategory.value ? { 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)
|
||||||
@@ -219,7 +231,7 @@ const otherInnerPos = computed(() =>
|
|||||||
const labelLengths = computed(() => ({
|
const labelLengths = computed(() => ({
|
||||||
storage: storageName.value.length,
|
storage: storageName.value.length,
|
||||||
free: 4,
|
free: 4,
|
||||||
other: 5
|
...(showOtherCategory.value ? { other: 5 } : {})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const getGapForPair = (len1: number, len2: number) => {
|
const getGapForPair = (len1: number, len2: number) => {
|
||||||
@@ -232,7 +244,9 @@ const adjustedLabelAngles = computed(() => {
|
|||||||
const labels = [
|
const labels = [
|
||||||
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||||
{ id: 'free', angle: angles.free, len: lens.free },
|
{ id: 'free', angle: angles.free, len: lens.free },
|
||||||
{ id: 'other', angle: angles.other, len: lens.other }
|
...(showOtherCategory.value
|
||||||
|
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
|
||||||
|
: [])
|
||||||
]
|
]
|
||||||
labels.sort((a, b) => a.angle - b.angle)
|
labels.sort((a, b) => a.angle - b.angle)
|
||||||
|
|
||||||
@@ -283,7 +297,11 @@ const freeLabelPath = computed(() =>
|
|||||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||||
)
|
)
|
||||||
const otherLabelPath = computed(() =>
|
const otherLabelPath = computed(() =>
|
||||||
createArcPath(adjustedLabelAngles.value.other!, 'other', 5)
|
createArcPath(
|
||||||
|
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
|
||||||
|
'other',
|
||||||
|
5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="showEmpty" class="empty-container">
|
||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Cog } from '@/assets/svg'
|
import { Cog } from '@/assets/svg'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const cog = Cog
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -21,9 +22,29 @@ const props = defineProps<{
|
|||||||
path: string[]
|
path: string[]
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showEmpty = computed(() => {
|
||||||
|
const loc = props.path.join('/')
|
||||||
|
const hasVisibleGhost = store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return g.loc === loc && !store.hiddenPaths.has(full)
|
||||||
|
})
|
||||||
|
|
||||||
|
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
|
|||||||
@@ -1,74 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<table v-if="props.documents.length || editing">
|
<div class="file-explorer">
|
||||||
<thead>
|
<table v-if="props.documents.length || editing">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="selection">
|
<tr>
|
||||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
<th class="selection">
|
||||||
</th>
|
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
</th>
|
||||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||||
<th class="menu"></th>
|
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||||
</tr>
|
<th class="menu"></th>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="editing?.key === 'new'" class="folder">
|
|
||||||
<td class="selection"></td>
|
|
||||||
<td class="name">
|
|
||||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
|
||||||
</td>
|
|
||||||
<FileModified :doc=editing :now=nowkey />
|
|
||||||
<FileSize :doc=editing />
|
|
||||||
<td class="menu"></td>
|
|
||||||
</tr>
|
|
||||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
|
||||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
|
||||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
<tr
|
<tbody>
|
||||||
:id="`file-${doc.key}`"
|
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
<td class="selection"></td>
|
||||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
|
||||||
@contextmenu.prevent="contextMenu($event, doc)"
|
|
||||||
>
|
|
||||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
tabindex="-1"
|
|
||||||
:checked="store.selected.has(doc.key)"
|
|
||||||
@change="
|
|
||||||
($event.target as HTMLInputElement).checked
|
|
||||||
? store.selected.add(doc.key)
|
|
||||||
: store.selected.delete(doc.key)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<template v-if="editing === doc">
|
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
|
||||||
{{ doc.name }}
|
|
||||||
</a>
|
|
||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<FileModified :doc=doc :now=nowkey />
|
|
||||||
<FileSize :doc=doc />
|
|
||||||
<td class="menu">
|
|
||||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
|
||||||
</td>
|
</td>
|
||||||
|
<FileModified :doc=editing :now=nowkey />
|
||||||
|
<FileSize :doc=editing />
|
||||||
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||||
<tr class="summary" v-if="props.documents.length > 1">
|
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
</tr>
|
||||||
<td class="menu"></td>
|
|
||||||
</tr>
|
<tr
|
||||||
</tbody>
|
:id="`file-${doc.key}`"
|
||||||
</table>
|
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||||
|
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||||
|
@contextmenu.prevent="contextMenu($event, doc)"
|
||||||
|
>
|
||||||
|
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tabindex="-1"
|
||||||
|
:checked="store.selected.has(doc.key)"
|
||||||
|
@change="
|
||||||
|
($event.target as HTMLInputElement).checked
|
||||||
|
? store.selected.add(doc.key)
|
||||||
|
: store.selected.delete(doc.key)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="name">
|
||||||
|
<template v-if="editing === doc">
|
||||||
|
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||||
|
{{ doc.name }}
|
||||||
|
</a>
|
||||||
|
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<FileModified :doc=doc :now=nowkey />
|
||||||
|
<FileSize :doc=doc />
|
||||||
|
<td class="menu">
|
||||||
|
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr class="summary" v-if="props.documents.length > 1">
|
||||||
|
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||||
|
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||||
|
<td class="menu"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -76,15 +79,17 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watchEffect
|
watch
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -112,27 +117,116 @@ const parseErrorMessage = async (res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
|
const exitEditing = () => {
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
console.log('New folder')
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
@@ -156,7 +250,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
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 (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${store.cursor} .name a`
|
||||||
@@ -176,14 +270,33 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) {
|
up(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
left(ev: KeyboardEvent) {
|
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) {
|
||||||
@@ -197,8 +310,6 @@ defineExpose({
|
|||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
},
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -207,35 +318,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 = store.cursor
|
const index = getCursorIndex()
|
||||||
? docs.findIndex(doc => doc.key === store.cursor)
|
|
||||||
: docs.length
|
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -248,24 +333,38 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
watchEffect(() => {
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
watch(
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
() => store.cursor,
|
||||||
if (store.cursor) {
|
cursor => {
|
||||||
const a = document.querySelector(
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
`#file-${store.cursor} .name a`
|
exitEditing()
|
||||||
) as HTMLAnchorElement | null
|
}
|
||||||
if (a) a.focus()
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
() => store.cursor,
|
||||||
store.cursor = ''
|
cursor => {
|
||||||
focusBreadcrumb()
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(
|
||||||
|
`#file-${cursor} .name a`
|
||||||
|
) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
|
store.cursor = ''
|
||||||
|
focusBreadcrumb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
let nowkey = ref(0)
|
let nowkey = ref(0)
|
||||||
let modifiedTimer: any = null
|
let modifiedTimer: any = null
|
||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
@@ -276,26 +375,47 @@ onMounted(() => {
|
|||||||
modifiedTimer = setInterval(updateModified, 1000)
|
modifiedTimer = setInterval(updateModified, 1000)
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
if (editing.value) exitEditing()
|
||||||
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
})
|
})
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const editRoute = (path: string) =>
|
||||||
|
'/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exitEditing()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
router.push(doc.urlrouter)
|
if (doc.dir) {
|
||||||
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Mkdir failed', err)
|
console.error('Create failed', err)
|
||||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -429,9 +549,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.file-explorer {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -562,12 +687,6 @@ tbody .selection input {
|
|||||||
content: '📁';
|
content: '📁';
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.empty-container {
|
|
||||||
padding-top: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.folder-change {
|
.folder-change {
|
||||||
margin-left: -.5rem;
|
margin-left: -.5rem;
|
||||||
}
|
}
|
||||||
@@ -578,4 +697,3 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ input#FileRenameInput {
|
|||||||
padding: .75em;
|
padding: .75em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
<GalleryFigure
|
||||||
|
:doc=doc
|
||||||
|
:editing="editing === doc ? {rename, exit} : null"
|
||||||
|
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||||
|
@menu="contextMenu($event, doc)"
|
||||||
|
@rename="editing = doc; store.cursor = doc.key"
|
||||||
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -13,15 +21,18 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watchEffect
|
watch
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -56,29 +67,215 @@ const exit = () => {
|
|||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
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 columnWidthPx = ref(240)
|
||||||
|
const emPx = ref(16)
|
||||||
|
const aspectByKey = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
const optimalRowHeightPx = (ratios: number[]) => {
|
||||||
|
const w = Math.max(1, columnWidthPx.value)
|
||||||
|
const minH = Math.max(1, Math.round(7 * emPx.value))
|
||||||
|
const maxH = Math.max(minH, Math.round(30 * emPx.value))
|
||||||
|
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
||||||
|
if (usable.length === 0) return Math.round(15 * emPx.value)
|
||||||
|
|
||||||
|
let bestH = Math.round(15 * emPx.value)
|
||||||
|
let bestScore = -1
|
||||||
|
for (let h = minH; h <= maxH; h++) {
|
||||||
|
let score = 0
|
||||||
|
for (const ar of usable) {
|
||||||
|
let shownW = w
|
||||||
|
let shownH = w * ar
|
||||||
|
if (shownH > h) {
|
||||||
|
shownH = h
|
||||||
|
shownW = h / ar
|
||||||
|
}
|
||||||
|
// Fill efficiency in the row cell (0..1)
|
||||||
|
score += (shownW * shownH) / (w * h)
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestH = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestH
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAspect = (key: string, ar: number) => {
|
||||||
|
if (!Number.isFinite(ar) || ar <= 0) return
|
||||||
|
if (aspectByKey.value[key] === ar) return
|
||||||
|
aspectByKey.value = {
|
||||||
|
...aspectByKey.value,
|
||||||
|
[key]: ar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowHeightsByKey = computed<Record<string, string>>(() => {
|
||||||
|
const docs = props.documents
|
||||||
|
const cols = Math.max(1, columnCount.value)
|
||||||
|
const byKey = aspectByKey.value
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
|
||||||
|
const assignRows = (group: Doc[]) => {
|
||||||
|
for (let start = 0; start < group.length; start += cols) {
|
||||||
|
const row = group.slice(start, start + cols)
|
||||||
|
const ratios = row
|
||||||
|
.filter(doc => doc.previewable)
|
||||||
|
.map(doc => byKey[doc.key])
|
||||||
|
.filter((ar): ar is number => ar != null)
|
||||||
|
const height = `${optimalRowHeightPx(ratios)}px`
|
||||||
|
for (const doc of row) out[doc.key] = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let group: Doc[] = []
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
|
||||||
|
assignRows(group)
|
||||||
|
group = []
|
||||||
|
}
|
||||||
|
group.push(docs[i]!)
|
||||||
|
}
|
||||||
|
assignRows(group)
|
||||||
|
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// Seed collected ratios from server-provided ar values on docs
|
||||||
|
const seedFromDocs = () => {
|
||||||
|
for (const doc of props.documents)
|
||||||
|
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImgLoad = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
|
||||||
|
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
|
||||||
|
if (!anchor) return
|
||||||
|
const key = anchor.id.slice('file-'.length)
|
||||||
|
if (!key) return
|
||||||
|
setAspect(key, img.naturalHeight / img.naturalWidth)
|
||||||
|
}
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
if (!gallery.value) return
|
if (!gallery.value) return
|
||||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
if (gallery.value.getBoundingClientRect().width <= 0) return
|
||||||
' '
|
const style = getComputedStyle(gallery.value)
|
||||||
).length
|
const templates = style.gridTemplateColumns
|
||||||
|
.split(' ')
|
||||||
|
.filter(part => !!part && part !== 'none')
|
||||||
|
columnCount.value = Math.max(1, templates.length)
|
||||||
|
const first = templates[0]
|
||||||
|
if (first && first.endsWith('px')) {
|
||||||
|
const parsed = Number.parseFloat(first)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
|
||||||
|
}
|
||||||
|
const parsedEm = Number.parseFloat(style.fontSize)
|
||||||
|
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||||
}
|
}
|
||||||
const columns = computed(() => columnCount.value)
|
const columns = computed(() => columnCount.value)
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
@@ -107,7 +304,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
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 (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor}`
|
`#file-${store.cursor}`
|
||||||
@@ -127,23 +324,42 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) {
|
up(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-columns.value, ev)
|
this.cursorMove(-columns.value, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(columns.value, ev)
|
this.cursorMove(columns.value, ev)
|
||||||
},
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -152,7 +368,7 @@ 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 = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
const index = getCursorIndex()
|
||||||
// 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
|
||||||
@@ -164,31 +380,7 @@ defineExpose({
|
|||||||
// Wrapping either end, just land outside the list
|
// Wrapping either end, just land outside the list
|
||||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||||
}
|
}
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -201,56 +393,109 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
watchEffect(() => {
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
watch(
|
||||||
if (editing.value) store.cursor = editing.value.key
|
() => store.cursor,
|
||||||
if (store.cursor) {
|
cursor => {
|
||||||
const a = document.querySelector(
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
`#file-${store.cursor}`
|
exit()
|
||||||
) as HTMLAnchorElement | null
|
|
||||||
if (a) {
|
|
||||||
a.focus()
|
|
||||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
() => store.cursor,
|
||||||
store.cursor = ''
|
cursor => {
|
||||||
focusBreadcrumb()
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||||
|
if (a) {
|
||||||
|
a.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
|
store.cursor = ''
|
||||||
|
focusBreadcrumb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
const attachGalleryObservers = () => {
|
||||||
|
if (!gallery.value || resizeObserver) return
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachGalleryObservers = () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
updateColumns()
|
updateColumns()
|
||||||
if (gallery.value) {
|
seedFromDocs()
|
||||||
resizeObserver = new ResizeObserver(updateColumns)
|
attachGalleryObservers()
|
||||||
resizeObserver.observe(gallery.value)
|
})
|
||||||
}
|
onActivated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateColumns()
|
||||||
|
attachGalleryObservers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
detachGalleryObservers()
|
||||||
|
if (editing.value) exit()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObserver?.disconnect()
|
keyboardFollowScroll.cancel()
|
||||||
|
detachGalleryObservers()
|
||||||
})
|
})
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
|
||||||
|
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||||
|
watch(() => props.documents, seedFromDocs)
|
||||||
|
const editRoute = (path: string) =>
|
||||||
|
'/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exit()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
router.push(doc.urlrouter)
|
if (doc.dir) {
|
||||||
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Mkdir failed', err)
|
console.error('Create failed', err)
|
||||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -380,7 +625,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
align-items: end;
|
align-items: start;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.folder-indicator {
|
.folder-indicator {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -10,21 +10,33 @@
|
|||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
<MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<div class="titlespacer"></div>
|
<div class="titlespacer"></div>
|
||||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
|
<div class="filename-row rename-row">
|
||||||
|
<div class="rename-wrap">
|
||||||
|
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
<div class="filename-row">
|
||||||
|
<span class="filename-group">
|
||||||
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
||||||
|
</div>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ snap.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
@@ -37,12 +49,14 @@ import { Doc } from '@/repositories/Document'
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
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()
|
||||||
|
const router = useRouter()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (name: string) => void
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void
|
exit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +74,27 @@ const sparseText = computed(() => {
|
|||||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Single subscription to docVersion; all doc-derived values come from here.
|
||||||
|
// This is needed because Doc instances are non-reactive plain objects, so
|
||||||
|
// mutating doc.name alone won't invalidate computed caches.
|
||||||
|
const snap = computed(() => {
|
||||||
|
void store.docVersion
|
||||||
|
const { name, ext } = props.doc
|
||||||
|
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
displayName: base.replace(/[_.]+/g, ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) {
|
||||||
|
ev.preventDefault()
|
||||||
|
} else if (props.doc.text) {
|
||||||
|
ev.preventDefault()
|
||||||
|
router.push(props.doc.editurl.replace('/#', ''))
|
||||||
|
}
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -81,8 +114,78 @@ const onclick = (ev: Event) => {
|
|||||||
.after-name {
|
.after-name {
|
||||||
margin-left: 0.3em;
|
margin-left: 0.3em;
|
||||||
}
|
}
|
||||||
|
.filename-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.filename-row::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
width: 1.4em;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.filename-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
cursor: default;
|
||||||
|
padding: .5em 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-ext {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
padding: 0 .15em 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.rename-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0.2em, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.filename-row:hover .rename-btn {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
|
max-height: var(--gallery-figure-height, 15em);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -92,12 +195,13 @@ figure {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: height 0.4s ease, max-height 0.4s ease;
|
||||||
}
|
}
|
||||||
figure > article {
|
figure > article {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
figure :deep(.video-container) {
|
figure :deep(.video-container) {
|
||||||
height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
}
|
}
|
||||||
.titlespacer {
|
.titlespacer {
|
||||||
flex-shrink: 100000;
|
flex-shrink: 100000;
|
||||||
@@ -114,9 +218,9 @@ figcaption {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
figcaption input[type='checkbox'] {
|
figcaption input[type='checkbox'] {
|
||||||
width: 1.5em;
|
width: 1.1em;
|
||||||
height: 1.5em;
|
height: 1.1em;
|
||||||
margin: .25em 0 .25em .25em;
|
margin: .25em .4em .25em .35em;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: opacity var(--transition-time) ease-in-out;
|
transition: opacity var(--transition-time) ease-in-out;
|
||||||
@@ -124,17 +228,10 @@ figcaption input[type='checkbox'] {
|
|||||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
figcaption span {
|
.cursor .filename {
|
||||||
cursor: default;
|
color: var(--accent-color);
|
||||||
padding: .5em;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.cursor figcaption span {
|
.cursor .file-ext {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
figcaption .namespacer {
|
figcaption .namespacer {
|
||||||
@@ -142,4 +239,17 @@ figcaption .namespacer {
|
|||||||
height: 2em;
|
height: 2em;
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
|
.rename-wrap {
|
||||||
|
font-size: 0.8em;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.rename-row {
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.rename-wrap :deep(#FileRenameInput) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain buttons">
|
<nav class="headermain buttons">
|
||||||
<UploadButton :path="props.path" />
|
<template v-if="!props.editorMode">
|
||||||
<SvgButton
|
<UploadButton :path="props.path" />
|
||||||
name="create-folder"
|
<SvgButton
|
||||||
tooltip="New folder"
|
name="create-file"
|
||||||
@click="() => { store.fileExplorer!.newFolder() }"
|
tooltip="New file"
|
||||||
/>
|
@click="() => { store.fileExplorer!.newFile() }"
|
||||||
<div class="smallgap"></div>
|
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
|
||||||
<div class="search-group">
|
|
||||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
|
||||||
<input
|
|
||||||
ref="search"
|
|
||||||
type="search"
|
|
||||||
:value="query"
|
|
||||||
@input="updateSearch"
|
|
||||||
@keydown.escape="clearSearch"
|
|
||||||
/>
|
/>
|
||||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
<SvgButton
|
||||||
</div>
|
name="create-folder"
|
||||||
<div v-if="showSortHints" class="sort-hints">
|
tooltip="New folder"
|
||||||
|
@click="() => { store.fileExplorer!.newFolder() }"
|
||||||
|
/>
|
||||||
|
<div class="smallgap"></div>
|
||||||
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
|
<div class="search-group">
|
||||||
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
|
<input
|
||||||
|
ref="search"
|
||||||
|
type="search"
|
||||||
|
:value="query"
|
||||||
|
@input="updateSearch"
|
||||||
|
@keydown.escape="clearSearch"
|
||||||
|
/>
|
||||||
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||||||
<span class="sort-label">Order</span>
|
<span class="sort-label">Order</span>
|
||||||
<span class="keycap">1</span>
|
<span class="keycap">1</span>
|
||||||
<span class="keycap">2</span>
|
<span class="keycap">2</span>
|
||||||
<span class="keycap">3</span>
|
<span class="keycap">3</span>
|
||||||
</div>
|
</div>
|
||||||
|
<SvgButton
|
||||||
|
v-if="props.editorMode"
|
||||||
|
name="disk"
|
||||||
|
tooltip="Save (Ctrl/Cmd+S)"
|
||||||
|
@click="store.editorSave?.()"
|
||||||
|
/>
|
||||||
<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" />
|
||||||
@@ -49,6 +62,7 @@ const textInputFocused = ref(false)
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
|
editorMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isInputElement = (el: Element | null): boolean => {
|
const isInputElement = (el: Element | null): boolean => {
|
||||||
@@ -164,6 +178,14 @@ const settingsMenu = (e: Event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'ℹ️ About Cista...',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'about'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
x: e.target.getBoundingClientRect().right,
|
x: e.target.getBoundingClientRect().right,
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||||
<SpinnerIcon />
|
<span
|
||||||
|
v-show="activeImageLoading"
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, 'loading-pulse']"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
|
<img
|
||||||
|
v-if="showPreviewImage"
|
||||||
|
:src="previewSrc"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !previewImageLoading }"
|
||||||
|
@load="onPreviewImageLoad"
|
||||||
|
@error="onPreviewImageError"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="doc.url"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !nativeImageLoading }"
|
||||||
|
@load="onNativeImageLoad"
|
||||||
|
@error="onNativeImageError"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||||
|
<span
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<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 }">
|
||||||
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
@@ -18,9 +44,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
import { Play as PlayIcon } from '@/assets/svg'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
import { computed, ref } from 'vue'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -29,11 +56,58 @@ const props = defineProps<{
|
|||||||
doc: Doc
|
doc: Doc
|
||||||
quality: string
|
quality: string
|
||||||
}>()
|
}>()
|
||||||
|
const previewImageFailed = ref(false)
|
||||||
|
const nativeImageFailed = ref(false)
|
||||||
|
const previewImageLoading = ref(true)
|
||||||
|
const nativeImageLoading = ref(true)
|
||||||
const previewSrc = computed(() =>
|
const previewSrc = computed(() =>
|
||||||
props.doc.previewurl
|
props.doc.previewurl
|
||||||
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
|
const showPreviewImage = computed(
|
||||||
|
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
|
||||||
|
)
|
||||||
|
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
|
||||||
|
const activeImageLoading = computed(() =>
|
||||||
|
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
|
||||||
|
)
|
||||||
|
const previewLoadFailed = computed(
|
||||||
|
() => previewImageFailed.value || nativeImageFailed.value
|
||||||
|
)
|
||||||
|
const loadingPulseDelayMs = computed(() => {
|
||||||
|
let hash = 0
|
||||||
|
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||||
|
return hash % 1800
|
||||||
|
})
|
||||||
|
const loadingPulseStyle = computed(() => ({
|
||||||
|
animationDelay: `${-loadingPulseDelayMs.value}ms`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onPreviewImageLoad = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onPreviewImageError = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
previewImageFailed.value = true
|
||||||
|
}
|
||||||
|
const onNativeImageLoad = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onNativeImageError = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
nativeImageFailed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.doc.key,
|
||||||
|
() => {
|
||||||
|
previewImageFailed.value = false
|
||||||
|
nativeImageFailed.value = false
|
||||||
|
previewImageLoading.value = true
|
||||||
|
nativeImageLoading.value = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const onplay = () => {
|
const onplay = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -125,64 +199,22 @@ defineExpose({
|
|||||||
media
|
media
|
||||||
})
|
})
|
||||||
|
|
||||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
const video = () => props.doc.video
|
||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
const audio = () => props.doc.audio
|
||||||
const archive = () =>
|
const archive = () => props.doc.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 = () => {
|
||||||
[
|
const store = useMainStore()
|
||||||
'bmp',
|
return (
|
||||||
'ico',
|
!(store.server.office_previews === false && docs()) &&
|
||||||
'tif',
|
(image() || print() || (props.doc.img && props.doc.size > 500000))
|
||||||
'tiff',
|
)
|
||||||
'heic',
|
}
|
||||||
'heif',
|
|
||||||
'pdf',
|
|
||||||
'epub',
|
|
||||||
'mobi',
|
|
||||||
// Documents
|
|
||||||
'doc',
|
|
||||||
'dot',
|
|
||||||
'docx',
|
|
||||||
'docm',
|
|
||||||
'dotx',
|
|
||||||
'dotm',
|
|
||||||
'rtf',
|
|
||||||
'odt',
|
|
||||||
'ott',
|
|
||||||
'txt',
|
|
||||||
'md',
|
|
||||||
'mhtml',
|
|
||||||
'mht',
|
|
||||||
'html',
|
|
||||||
'htm',
|
|
||||||
'xml',
|
|
||||||
'wps',
|
|
||||||
'wri',
|
|
||||||
// Spreadsheets
|
|
||||||
'xls',
|
|
||||||
'xlsx',
|
|
||||||
'xlsm',
|
|
||||||
'xlsb',
|
|
||||||
'xltx',
|
|
||||||
'xltm',
|
|
||||||
'ods',
|
|
||||||
'ots',
|
|
||||||
'csv',
|
|
||||||
// Presentations
|
|
||||||
'ppt',
|
|
||||||
'pptx',
|
|
||||||
'pptm',
|
|
||||||
'pps',
|
|
||||||
'ppsx',
|
|
||||||
'pot',
|
|
||||||
'potx',
|
|
||||||
'odp',
|
|
||||||
'otp'
|
|
||||||
].includes(props.doc.ext) ||
|
|
||||||
(props.doc.size > 500000 &&
|
|
||||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -195,6 +227,7 @@ img, embed, .icon, audio, video {
|
|||||||
border-radius: calc(.5em / 8);
|
border-radius: calc(.5em / 8);
|
||||||
}
|
}
|
||||||
.preview-progress {
|
.preview-progress {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -203,18 +236,47 @@ img, embed, .icon, audio, video {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
.preview-progress :deep(svg) {
|
.preview-progress .icon {
|
||||||
width: 4.5em;
|
opacity: 0.9;
|
||||||
height: 4.5em;
|
|
||||||
opacity: 0.8;
|
|
||||||
animation: media-preview-spin 0.9s linear infinite;
|
|
||||||
}
|
}
|
||||||
@keyframes media-preview-spin {
|
.preview-image-shell {
|
||||||
from {
|
position: relative;
|
||||||
transform: rotate(0deg);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.preview-image-shell img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.preview-image-shell img.ready {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.loading-pulse {
|
||||||
|
animation: media-preview-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes media-preview-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
}
|
}
|
||||||
to {
|
50% {
|
||||||
transform: rotate(360deg);
|
transform: scale(1.04);
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.folder::before {
|
.folder::before {
|
||||||
@@ -260,12 +322,6 @@ img, embed, .icon, audio, video {
|
|||||||
figure.cursor .icon {
|
figure.cursor .icon {
|
||||||
filter: brightness(1);
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
img::before {
|
|
||||||
/* broken image */
|
|
||||||
text-shadow: 0 0 .5rem #000;
|
|
||||||
filter: grayscale(1);
|
|
||||||
content: '❌';
|
|
||||||
}
|
|
||||||
.video-container {
|
.video-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,15 +15,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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'
|
import { nextTick, onBeforeUnmount, ref, watch, 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)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
let backdropHeld = false
|
||||||
|
|
||||||
|
const ensureGlobalBackdropStyles = () => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (document.getElementById('paskia-dialog')) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'paskia-dialog'
|
||||||
|
style.textContent = `body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1099;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(0) brightness(1);
|
||||||
|
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop::before {
|
||||||
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#paskia-iframe {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.insertBefore(style, document.head.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
releaseGlobalBackdrop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -33,7 +73,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
holdGlobalBackdrop()
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
overlay.value?.focus()
|
overlay.value?.focus()
|
||||||
const input = dialog.value?.querySelector('input')
|
const input = dialog.value?.querySelector('input')
|
||||||
@@ -41,6 +80,29 @@ const show = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ show, close })
|
defineExpose({ show, close })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.dialog === props.name,
|
||||||
|
isOpen => {
|
||||||
|
if (isOpen && !backdropHeld) {
|
||||||
|
ensureGlobalBackdropStyles()
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
backdropHeld = true
|
||||||
|
} else if (!isOpen && backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (overlay.value) {
|
if (overlay.value) {
|
||||||
overlay.value.focus()
|
overlay.value.focus()
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="select-size">{{ selectionDisplay.size }}</span>
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
<DownloadButton />
|
<DownloadButton />
|
||||||
<button
|
<SvgButton
|
||||||
class="action-button"
|
name="link"
|
||||||
title="Copy share link (Alt-click for read/write)"
|
tooltip="Copy share link (Alt-click for read/write)"
|
||||||
@click="copyShareLink"
|
@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,7 +29,7 @@
|
|||||||
@mouseenter="unselectTooltip?.startHover"
|
@mouseenter="unselectTooltip?.startHover"
|
||||||
@mousemove="unselectTooltip?.updatePosition"
|
@mousemove="unselectTooltip?.updatePosition"
|
||||||
@mouseleave="unselectTooltip?.endHover"
|
@mouseleave="unselectTooltip?.endHover"
|
||||||
>✖ selection</button>
|
>✖ deselect</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils'
|
import { collator, formatSize } from '@/utils'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ type InflightBlock = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||||
|
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
|
||||||
function pasteHandler(event: ClipboardEvent) {
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
const infiles = [] as File[]
|
const infiles = [] as File[]
|
||||||
@@ -116,6 +117,28 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
}
|
}
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||||
|
|
||||||
|
// Space check: reject the whole batch if there isn't enough free space.
|
||||||
|
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
|
||||||
|
const allDocs = getDocuments()
|
||||||
|
const docByPath = new Map<string, Doc>()
|
||||||
|
for (const d of allDocs) {
|
||||||
|
const path = d.loc ? `${d.loc}/${d.name}` : d.name
|
||||||
|
docByPath.set(path, d)
|
||||||
|
}
|
||||||
|
let overwriteSize = 0
|
||||||
|
for (const f of files) {
|
||||||
|
const existing = docByPath.get(f.cloudName)
|
||||||
|
if (existing && !existing.dir) overwriteSize += existing.size
|
||||||
|
}
|
||||||
|
const netNeed = batchTotal - overwriteSize
|
||||||
|
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
|
||||||
|
store.showToast(
|
||||||
|
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistic update: ghost folders and files
|
// Optimistic update: ghost folders and files
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const docs = getDocuments()
|
const docs = getDocuments()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export type DocProps = {
|
|||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
expires?: number // Unix timestamp for ghost expiry
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
|
ar?: number // Aspect ratio (height/width) from server, if known
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
@@ -25,6 +27,7 @@ export class Doc {
|
|||||||
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 = ''
|
||||||
|
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
@@ -63,86 +66,61 @@ export class Doc {
|
|||||||
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',
|
get video(): boolean {
|
||||||
'png',
|
return (FILE_TYPES.video as readonly string[]).includes(this.ext)
|
||||||
'gif',
|
}
|
||||||
'webp',
|
get audio(): boolean {
|
||||||
'avif',
|
return (FILE_TYPES.audio as readonly string[]).includes(this.ext)
|
||||||
'heic',
|
}
|
||||||
'heif',
|
get archive(): boolean {
|
||||||
'svg'
|
return (FILE_TYPES.archive as readonly string[]).includes(this.ext)
|
||||||
].includes(this.ext)
|
}
|
||||||
|
get document(): boolean {
|
||||||
|
return (FILE_TYPES.document as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
|
get image(): boolean {
|
||||||
|
return (FILE_TYPES.image as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get print(): boolean {
|
||||||
|
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get text(): boolean {
|
||||||
|
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get editurl(): string {
|
||||||
|
if (!this.text) return ''
|
||||||
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
|
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
}
|
}
|
||||||
get complete(): boolean {
|
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 [
|
this.video ||
|
||||||
'mp4',
|
this.audio ||
|
||||||
'mkv',
|
this.image ||
|
||||||
'webm',
|
this.print ||
|
||||||
'ogg',
|
(this.document && useMainStore().server.office_previews !== false)
|
||||||
'mp3',
|
)
|
||||||
'flac',
|
|
||||||
'aac',
|
|
||||||
'pdf',
|
|
||||||
// Documents
|
|
||||||
'doc',
|
|
||||||
'dot',
|
|
||||||
'docx',
|
|
||||||
'docm',
|
|
||||||
'dotx',
|
|
||||||
'dotm',
|
|
||||||
'rtf',
|
|
||||||
'odt',
|
|
||||||
'ott',
|
|
||||||
'txt',
|
|
||||||
'md',
|
|
||||||
'mhtml',
|
|
||||||
'mht',
|
|
||||||
'html',
|
|
||||||
'htm',
|
|
||||||
'xml',
|
|
||||||
'wps',
|
|
||||||
'wri',
|
|
||||||
// Spreadsheets
|
|
||||||
'xls',
|
|
||||||
'xlsx',
|
|
||||||
'xlsm',
|
|
||||||
'xlsb',
|
|
||||||
'xltx',
|
|
||||||
'xltm',
|
|
||||||
'ods',
|
|
||||||
'ots',
|
|
||||||
'csv',
|
|
||||||
// Presentations
|
|
||||||
'ppt',
|
|
||||||
'pptx',
|
|
||||||
'pptm',
|
|
||||||
'pps',
|
|
||||||
'ppsx',
|
|
||||||
'pot',
|
|
||||||
'potx',
|
|
||||||
'odp',
|
|
||||||
'otp'
|
|
||||||
].includes(this.ext)
|
|
||||||
}
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
if (!this.complete || !this.previewable) 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 = {
|
||||||
@@ -162,7 +140,8 @@ export type FileEntry = [
|
|||||||
number, // mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
number, // allocated (actual disk usage)
|
number, // allocated (actual disk usage)
|
||||||
number // isfile
|
number, // isfile
|
||||||
|
number? // ar: aspect ratio (height/width), present if known
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
|
|||||||
case !!msg.update:
|
case !!msg.update:
|
||||||
handleUpdateMessage(msg)
|
handleUpdateMessage(msg)
|
||||||
break
|
break
|
||||||
|
case !!msg.ar: {
|
||||||
|
const store = useMainStore()
|
||||||
|
store.updateAr(msg.ar as Record<string, number>)
|
||||||
|
break
|
||||||
|
}
|
||||||
case !!msg.space:
|
case !!msg.space:
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
store.space = msg.space
|
store.space = msg.space
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import ExplorerView from '@/views/ExplorerView.vue'
|
import ExplorerView from '@/views/ExplorerView.vue'
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
function getPathDepth(path: string): number {
|
||||||
|
const pathPart = decodeURIComponent(path).split('//')[0] ?? ''
|
||||||
|
return pathPart.split('/').filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -12,4 +18,17 @@ const router = createRouter({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
const store = useMainStore()
|
||||||
|
const toDepth = getPathDepth(to.path)
|
||||||
|
const fromDepth = getPathDepth(from.path)
|
||||||
|
if (toDepth > fromDepth) {
|
||||||
|
store.transitionDirection = 'forward'
|
||||||
|
} else if (toDepth < fromDepth) {
|
||||||
|
store.transitionDirection = 'backward'
|
||||||
|
} else {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
|
|||||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||||
import SearchWorker from '@/workers/searchWorker?worker'
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
import { type StateTree, defineStore } from 'pinia'
|
import { type StateTree, defineStore } from 'pinia'
|
||||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
|
||||||
|
|
||||||
// Singleton search worker instance
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -79,8 +79,12 @@ export const useMainStore = defineStore('main', {
|
|||||||
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' | 'tokens',
|
public?: boolean
|
||||||
|
paskia?: boolean
|
||||||
|
office_previews?: boolean
|
||||||
|
},
|
||||||
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
@@ -94,6 +98,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
privileged: false as boolean,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false as boolean
|
isLoggedIn: false as boolean
|
||||||
},
|
},
|
||||||
|
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
|
||||||
|
editorSave: null as null | (() => void),
|
||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
@@ -120,7 +126,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(
|
docs.push(
|
||||||
new Doc({
|
new Doc({
|
||||||
@@ -130,7 +136,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
size,
|
size,
|
||||||
allocated,
|
allocated,
|
||||||
mtime,
|
mtime,
|
||||||
dir: !isfile
|
dir: !isfile,
|
||||||
|
ar
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
@@ -153,6 +160,22 @@ export const useMainStore = defineStore('main', {
|
|||||||
// Sync documents to search worker
|
// Sync documents to search worker
|
||||||
this.syncSearchWorker()
|
this.syncSearchWorker()
|
||||||
},
|
},
|
||||||
|
/** Patch aspect ratios on existing docs from a server ar update message */
|
||||||
|
updateAr(arMap: Record<string, number>) {
|
||||||
|
const docs = getDocuments()
|
||||||
|
let changed = false
|
||||||
|
for (const doc of docs) {
|
||||||
|
const ar = arMap[doc.key]
|
||||||
|
if (ar != null && doc.ar !== ar) {
|
||||||
|
doc.ar = ar
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
}
|
||||||
|
},
|
||||||
/** Add a ghost file/folder for optimistic UI updates */
|
/** Add a ghost file/folder for optimistic UI updates */
|
||||||
addGhost(doc: Doc) {
|
addGhost(doc: Doc) {
|
||||||
doc.ghost = true
|
doc.ghost = true
|
||||||
@@ -232,6 +255,12 @@ export const useMainStore = defineStore('main', {
|
|||||||
}))
|
}))
|
||||||
worker.postMessage({ type: 'update', documents: docData })
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
},
|
},
|
||||||
|
/** Notify UI/search that existing document objects were mutated in-place */
|
||||||
|
documentsChanged() {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
search(query: string, loc: string) {
|
search(query: string, loc: string) {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
const id = ++searchId
|
const id = ++searchId
|
||||||
@@ -299,6 +328,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
this.connected = false
|
this.connected = false
|
||||||
this.dialog = ''
|
this.dialog = ''
|
||||||
this.cursor = ''
|
this.cursor = ''
|
||||||
|
this.editorSave = null
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log('Logout')
|
console.log('Logout')
|
||||||
|
|||||||
@@ -5,10 +5,18 @@ 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
|
||||||
|
if (path.length === 0) return true
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return getDocuments().some(
|
const hidden = store.hiddenPaths
|
||||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
const inDocs = getDocuments().some(doc => {
|
||||||
)
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
|
if (inDocs) return true
|
||||||
|
return store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
+76
-12
@@ -69,23 +69,87 @@ 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'],
|
||||||
|
text: [
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'cfg',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
|
'py',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'jsx',
|
||||||
|
'tsx',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'sass',
|
||||||
|
'less',
|
||||||
|
'vue',
|
||||||
|
'php',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'java',
|
||||||
|
'c',
|
||||||
|
'cpp',
|
||||||
|
'h',
|
||||||
|
'hpp',
|
||||||
|
'cs',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'zsh',
|
||||||
|
'fish',
|
||||||
|
'ps1',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'sql',
|
||||||
|
'lua',
|
||||||
|
'r',
|
||||||
|
'pl',
|
||||||
|
'dockerfile',
|
||||||
|
'makefile',
|
||||||
|
'gitignore',
|
||||||
|
'gitattributes',
|
||||||
|
'env',
|
||||||
|
'diff',
|
||||||
|
'patch'
|
||||||
|
]
|
||||||
|
} as const
|
||||||
|
|
||||||
export function getFileType(name: string): string {
|
export type FileCategory = keyof typeof FILE_TYPES
|
||||||
|
|
||||||
|
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 (
|
for (const category of Object.keys(FILE_TYPES) as FileCategory[]) {
|
||||||
Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
if ((FILE_TYPES[category] as readonly string[]).includes(ext)) {
|
||||||
)
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prebuilt for fast & consistent sorting
|
// Prebuilt for fast & consistent sorting
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
type ScrollOptions = {
|
||||||
|
topPad?: number
|
||||||
|
bottomPad?: number
|
||||||
|
keyboardWindowMs?: number
|
||||||
|
getScrollContainer?: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
|
||||||
|
const {
|
||||||
|
topPad = 84,
|
||||||
|
bottomPad = 84,
|
||||||
|
keyboardWindowMs = 260,
|
||||||
|
getScrollContainer = () =>
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let scrollAnimationFrame: number | null = null
|
||||||
|
let scrollTargetY: number | null = null
|
||||||
|
let scrollVelocity = 0
|
||||||
|
let keyboardFollowUntil = 0
|
||||||
|
|
||||||
|
const markKeyboardFollow = () => {
|
||||||
|
keyboardFollowUntil = performance.now() + keyboardWindowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
||||||
|
|
||||||
|
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
||||||
|
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||||
|
return Math.min(maxY, Math.max(0, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
const visibleTop = scrollerRect.top + topPad
|
||||||
|
const visibleBottom = scrollerRect.bottom - bottomPad
|
||||||
|
|
||||||
|
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
||||||
|
|
||||||
|
if (rect.top < visibleTop) {
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSmoothCursorScroll = () => {
|
||||||
|
if (scrollAnimationFrame != null) return
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
|
||||||
|
if (scrollTargetY == null) {
|
||||||
|
scrollVelocity *= 0.68
|
||||||
|
if (Math.abs(scrollVelocity) > 0.05) {
|
||||||
|
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = scroller.scrollTop
|
||||||
|
const delta = scrollTargetY - current
|
||||||
|
const absDelta = Math.abs(delta)
|
||||||
|
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
||||||
|
scroller.scrollTop = scrollTargetY
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
|
||||||
|
const damping = 0.76
|
||||||
|
scrollVelocity += delta * stiffness
|
||||||
|
scrollVelocity *= damping
|
||||||
|
|
||||||
|
const next = clampScrollY(current + scrollVelocity, scroller)
|
||||||
|
if (next === current) scrollVelocity = 0
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepVisible = (el: HTMLElement | null) => {
|
||||||
|
if (!keyboardFollowActive()) {
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = cursorScrollTarget(el)
|
||||||
|
if (target == null) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTargetY = target
|
||||||
|
runSmoothCursorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
keyboardFollowUntil = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markKeyboardFollow, keepVisible, cancel }
|
||||||
|
}
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<Gallery
|
<div class="transition-wrapper">
|
||||||
v-if="store.prefs.gallery"
|
<Transition
|
||||||
ref="fileExplorer"
|
:name="transitionName"
|
||||||
:key="`gallery-${folderPath}`"
|
@after-enter="onAfterEnter"
|
||||||
:path="props.path"
|
>
|
||||||
:documents="documents"
|
<KeepAlive>
|
||||||
/>
|
<component
|
||||||
<FileExplorer
|
:is="store.prefs.gallery ? Gallery : FileExplorer"
|
||||||
v-else
|
:key="cacheKey"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${folderPath}`"
|
class="explorer-content"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
import Gallery from '@/components/Gallery.vue'
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
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 { computed, ref, watch, watchEffect } from 'vue'
|
import { computed, nextTick, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@@ -34,6 +37,31 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// Folder path for component keys - only recreate component when folder changes, not search
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
const folderPath = computed(() => props.path.join('/'))
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
const cacheKey = computed(
|
||||||
|
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const transitionName = computed(() => {
|
||||||
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderScrollTop = new Map<string, number>()
|
||||||
|
const scrollKey = (path: string) => path || '/'
|
||||||
|
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
|
||||||
|
|
||||||
|
const restoreScroll = (path: string) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (!scroller) return
|
||||||
|
const top = folderScrollTop.get(scrollKey(path)) ?? 0
|
||||||
|
scroller.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAfterEnter = () => {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
restoreScroll(folderPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle route-based search changes (back/forward navigation, direct URL)
|
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||||
// Skip if store.query already matches (means we triggered this via typing)
|
// Skip if store.query already matches (means we triggered this via typing)
|
||||||
@@ -87,6 +115,19 @@ watchEffect(() => {
|
|||||||
store.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
folderPath,
|
||||||
|
async (path, oldPath) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (scroller && oldPath !== undefined) {
|
||||||
|
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => restoreScroll(path))
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
watch(
|
watch(
|
||||||
[() => props.path.join('/'), () => store.documentCount],
|
[() => props.path.join('/'), () => store.documentCount],
|
||||||
@@ -100,16 +141,6 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.empty-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 2rem;
|
|
||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.search-loading {
|
.search-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-editor">
|
||||||
|
<div class="editor-body">
|
||||||
|
<div v-if="loading" class="status">Loading…</div>
|
||||||
|
<div v-else-if="error" class="status error">{{ error }}</div>
|
||||||
|
<div v-else ref="editorHost" class="editor-host"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
|
import { LanguageDescription } from '@codemirror/language'
|
||||||
|
import { languages } from '@codemirror/language-data'
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import { EditorView, keymap } from '@codemirror/view'
|
||||||
|
import { basicSetup } from 'codemirror'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref
|
||||||
|
} from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
||||||
|
|
||||||
|
const filePath = computed(() => {
|
||||||
|
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
|
||||||
|
return raw.replace(/^\//, '').replace(/\/$/, '')
|
||||||
|
})
|
||||||
|
const filename = computed(() => filePath.value.split('/').pop() || '')
|
||||||
|
|
||||||
|
const filesUrl = computed(() => {
|
||||||
|
return (
|
||||||
|
'/files/' +
|
||||||
|
filePath.value
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const original = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const editorHost = ref<HTMLDivElement | null>(null)
|
||||||
|
let editorView: EditorView | null = null
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
const dirty = computed(() => content.value !== original.value)
|
||||||
|
|
||||||
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!dirty.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let beforeUnloadActive = false
|
||||||
|
|
||||||
|
const activateEditorBindings = () => {
|
||||||
|
store.editorSave = save
|
||||||
|
if (!beforeUnloadActive) {
|
||||||
|
window.addEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateEditorBindings = () => {
|
||||||
|
if (store.editorSave === save) {
|
||||||
|
store.editorSave = null
|
||||||
|
}
|
||||||
|
if (beforeUnloadActive) {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectLanguage = async () => {
|
||||||
|
const language = LanguageDescription.matchFilename(languages, filename.value)
|
||||||
|
if (!language) return []
|
||||||
|
try {
|
||||||
|
return [await language.load()]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initEditor = async (text: string) => {
|
||||||
|
if (!editorHost.value) return
|
||||||
|
const languageExtensions = await detectLanguage()
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: text,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
oneDark,
|
||||||
|
languageCompartment.of(languageExtensions),
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
content.value = update.state.doc.toString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Mod-s',
|
||||||
|
run: () => {
|
||||||
|
void save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indentWithTab
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
editorView = new EditorView({ state, parent: editorHost.value })
|
||||||
|
editorView.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving.value || loading.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(filesUrl.value, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: content.value,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
original.value = content.value
|
||||||
|
store.showToast(`Saved ${filename.value}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed', err)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
activateEditorBindings()
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(filesUrl.value, { method: 'HEAD' })
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
|
const size = Number(res.headers.get('content-length') || '0')
|
||||||
|
if (size > MAX_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const textRes = await fetch(filesUrl.value)
|
||||||
|
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
|
||||||
|
const text = await textRes.text()
|
||||||
|
content.value = text
|
||||||
|
original.value = text
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
await initEditor(text)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||||
|
} finally {
|
||||||
|
if (loading.value) loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
activateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
editorView?.destroy()
|
||||||
|
editorView = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ddd;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.editor-host {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-editor) {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-scroller) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content) {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-focused .cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content ::selection) {
|
||||||
|
background: var(--soft-color, #146);
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content, .cm-gutter) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
color: #f55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+4
-11
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -130,7 +130,6 @@ ignore = [
|
|||||||
"ANN202", # 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
|
"ANN204", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"ARG001", # framework and callback signatures commonly require unused args
|
|
||||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||||
"C901", # legacy complexity; keep other correctness rules enabled
|
"C901", # legacy complexity; keep other correctness rules enabled
|
||||||
"D100", # legacy docs not yet standardized
|
"D100", # legacy docs not yet standardized
|
||||||
@@ -152,22 +151,16 @@ ignore = [
|
|||||||
"EM101", # exception-message style; low signal for this project
|
"EM101", # exception-message style; low signal for this project
|
||||||
"EM102", # exception-message style; low signal for this project
|
"EM102", # exception-message style; low signal for this project
|
||||||
"INP001", # scripts folder intentionally lacks package markers
|
"INP001", # scripts folder intentionally lacks package markers
|
||||||
"PLC0415", # lazy imports used to avoid startup/circular import issues
|
|
||||||
"PLR0911", # legacy complexity; keep other correctness rules enabled
|
"PLR0911", # legacy complexity; keep other correctness rules enabled
|
||||||
"PLR0912", # legacy complexity; keep other correctness rules enabled
|
"PLR0912", # legacy complexity; keep other correctness rules enabled
|
||||||
"PLR0913", # legacy complexity; keep other correctness rules enabled
|
"PLR0913", # legacy complexity; keep other correctness rules enabled
|
||||||
"PLR0915", # legacy complexity; keep other correctness rules enabled
|
"PLR0915", # legacy complexity; keep other correctness rules enabled
|
||||||
"PLR2004", # legacy comparisons use inline constants
|
"PLR2004", # we like magic numbers (don't remove this suppression)
|
||||||
"PLW0603", # module-level shared state exists in server runtime code
|
"PLW0603", # module-level shared state exists in server runtime code
|
||||||
"SLF001", # cohesive modules occasionally need private-member access
|
|
||||||
"TRY002", # exception-class strictness too noisy on legacy handlers
|
|
||||||
"TRY003", # exception-message strictness too noisy on legacy handlers
|
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||||
"TRY004", # type-check strictness too noisy on legacy handlers
|
|
||||||
"TRY300", # stylistic try/else preference
|
|
||||||
"TRY301", # stylistic raise-in-try preference
|
|
||||||
]
|
]
|
||||||
isort.known-first-party = ["cista"]
|
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]
|
||||||
|
|||||||
+10
-9
@@ -16,15 +16,16 @@ 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 ( # type: ignore[import-not-found]
|
from devutil import ( # type: ignore[import-not-found]
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
ready,
|
ready,
|
||||||
setup_vite,
|
setup_vite,
|
||||||
@@ -33,7 +34,9 @@ from devutil import ( # type: ignore[import-not-found]
|
|||||||
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(
|
||||||
@@ -64,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
|
||||||
@@ -72,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,
|
||||||
@@ -102,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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Hatch build hook for building Vue frontend during package build."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import (
|
|
||||||
BuildHookInterface, # type: ignore[import-not-found]
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from buildutil import build
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data):
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
build("frontend")
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
|
"""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)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -41,74 +43,108 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
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.
|
||||||
@@ -146,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]]
|
||||||
@@ -179,10 +215,10 @@ 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) from e
|
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) # noqa: S603
|
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||||
|
|
||||||
@@ -190,5 +226,5 @@ def build(folder: str = "frontend") -> None:
|
|||||||
run(install_cmd)
|
run(install_cmd)
|
||||||
logger.info("")
|
logger.info("")
|
||||||
run(build_cmd)
|
run(build_cmd)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError:
|
||||||
raise SystemExit(1) from e
|
raise SystemExit(1) from None
|
||||||
|
|||||||
+134
-63
@@ -1,111 +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 contextlib
|
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:
|
||||||
with contextlib.suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
p.terminate()
|
p.terminate()
|
||||||
|
|
||||||
# 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:
|
)
|
||||||
with contextlib.suppress(ProcessLookupError):
|
except TimeoutError:
|
||||||
p.kill()
|
for p in self._procs:
|
||||||
await p.wait()
|
if p.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
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!")
|
except httpx.RequestError:
|
||||||
return
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
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) from e
|
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")
|
||||||
@@ -118,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)
|
||||||
@@ -141,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,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"]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import errno
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
from unittest.mock import patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import config, watching
|
||||||
|
from cista.api import fileserver
|
||||||
|
from cista.fileserver import bp as fileserver_bp
|
||||||
|
|
||||||
|
|
||||||
|
class Usage(NamedTuple):
|
||||||
|
total: int
|
||||||
|
used: int
|
||||||
|
free: int
|
||||||
|
|
||||||
|
|
||||||
|
def _low_disk_usage(*args, **kwargs):
|
||||||
|
return Usage(total=1000, used=900, free=10)
|
||||||
|
|
||||||
|
|
||||||
|
@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"disk-space-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
app.router.ALLOWED_METHODS = (
|
||||||
|
*app.router.ALLOWED_METHODS,
|
||||||
|
"MKCOL",
|
||||||
|
"MOVE",
|
||||||
|
"COPY",
|
||||||
|
"PROPFIND",
|
||||||
|
)
|
||||||
|
app.blueprint(fileserver_bp)
|
||||||
|
await fileserver.start()
|
||||||
|
yield app.asgi_client
|
||||||
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejected_when_disk_low(client):
|
||||||
|
with patch("cista.util.diskspace.shutil.disk_usage", side_effect=_low_disk_usage):
|
||||||
|
_, res = await client.put("/files/test.txt", data=b"hello world")
|
||||||
|
assert res.status_code == 507
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejected_on_enospc(client):
|
||||||
|
with patch(
|
||||||
|
"cista.fileio.os.write",
|
||||||
|
side_effect=OSError(errno.ENOSPC, "No space left on device"),
|
||||||
|
):
|
||||||
|
_, res = await client.put("/files/test.txt", data=b"hello world")
|
||||||
|
assert res.status_code == 507
|
||||||
@@ -3,11 +3,11 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import struct
|
import struct
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import time
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from Crypto.Hash import MD4
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
|
||||||
from cista import auth, config, session, watching
|
from cista import auth, config, session, watching
|
||||||
@@ -29,8 +29,6 @@ def _ntlm_type3(
|
|||||||
username: str, password: str, domain: str, challenge: bytes
|
username: str, password: str, domain: str, challenge: bytes
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Build an NTLMv2 Type 3 message for testing."""
|
"""Build an NTLMv2 Type 3 message for testing."""
|
||||||
from Crypto.Hash import MD4
|
|
||||||
|
|
||||||
# NT hash
|
# NT hash
|
||||||
nt_hash = MD4.new(password.encode("utf-16le")).digest()
|
nt_hash = MD4.new(password.encode("utf-16le")).digest()
|
||||||
# NTLMv2 hash
|
# NTLMv2 hash
|
||||||
@@ -94,10 +92,7 @@ def _ntlm_type3(
|
|||||||
|
|
||||||
def _session_cookie_header(username: str) -> dict[str, str]:
|
def _session_cookie_header(username: str) -> dict[str, str]:
|
||||||
token = "test-" + username
|
token = "test-" + username
|
||||||
session._sessions[token] = {
|
session.put(token, username)
|
||||||
"exp": int(time()) + session.max_age,
|
|
||||||
"username": username,
|
|
||||||
}
|
|
||||||
return {"Cookie": f"cista={token}"}
|
return {"Cookie": f"cista={token}"}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +115,12 @@ def setup_storage(tmp_path: Path):
|
|||||||
mode="rw",
|
mode="rw",
|
||||||
share_paths=["docs"],
|
share_paths=["docs"],
|
||||||
)
|
)
|
||||||
|
share_anon = config.Token(
|
||||||
|
key="share_anon_123",
|
||||||
|
kind="share",
|
||||||
|
mode="ro",
|
||||||
|
share_paths=["docs"],
|
||||||
|
)
|
||||||
config.config = config.Config(
|
config.config = config.Config(
|
||||||
path=tmp_path,
|
path=tmp_path,
|
||||||
listen=":0",
|
listen=":0",
|
||||||
@@ -129,6 +130,7 @@ def setup_storage(tmp_path: Path):
|
|||||||
"test_token_123": token,
|
"test_token_123": token,
|
||||||
"share_ro_123": share_ro,
|
"share_ro_123": share_ro,
|
||||||
"share_rw_123": share_rw,
|
"share_rw_123": share_rw,
|
||||||
|
"share_anon_123": share_anon,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watching.state.root = []
|
watching.state.root = []
|
||||||
@@ -290,3 +292,20 @@ async def test_share_token_rw_allows_writes_in_scope_only(client):
|
|||||||
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
|
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
|
||||||
)
|
)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_share_token_requires_public_mode(client):
|
||||||
|
config.config.public = True
|
||||||
|
|
||||||
|
_, res = await client.get(
|
||||||
|
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.body == b"A"
|
||||||
|
|
||||||
|
config.config.public = False
|
||||||
|
_, res = await client.get(
|
||||||
|
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ async def test_mkcol_windows_drive_path_stays_within_root(client, setup_storage:
|
|||||||
# Either created inside the storage root (201) or sanitised away (400/404).
|
# Either created inside the storage root (201) or sanitised away (400/404).
|
||||||
# The important assertion: nothing was created outside the storage root.
|
# The important assertion: nothing was created outside the storage root.
|
||||||
assert not (Path("/c:") / "secret").exists()
|
assert not (Path("/c:") / "secret").exists()
|
||||||
assert not (Path("c:/secret")).exists()
|
assert not (Path("c:/secret")).exists() # noqa: ASYNC240
|
||||||
if res.status_code == 201:
|
if res.status_code == 201:
|
||||||
# Created safely inside tmp storage
|
# Created safely inside tmp storage
|
||||||
assert (setup_storage / "c:" / "secret").is_dir()
|
assert (setup_storage / "c:" / "secret").is_dir()
|
||||||
|
|||||||
+17
-5
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePath
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
@@ -12,10 +13,6 @@ from cista.auth import bp as auth_bp
|
|||||||
|
|
||||||
|
|
||||||
def _persist_config():
|
def _persist_config():
|
||||||
from pathlib import PurePath
|
|
||||||
|
|
||||||
import msgspec
|
|
||||||
|
|
||||||
def enc_hook(obj):
|
def enc_hook(obj):
|
||||||
if isinstance(obj, PurePath):
|
if isinstance(obj, PurePath):
|
||||||
return obj.as_posix()
|
return obj.as_posix()
|
||||||
@@ -218,3 +215,18 @@ async def test_create_share_token(client):
|
|||||||
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
||||||
assert len(share_tokens) == 1
|
assert len(share_tokens) == 1
|
||||||
assert share_tokens[0]["mode"] == "ro"
|
assert share_tokens[0]["mode"] == "ro"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_share_token_public_anonymous(client):
|
||||||
|
config.config = msgspec.structs.replace(config.config, public=True)
|
||||||
|
|
||||||
|
_, res = await client.post(
|
||||||
|
"/api/share-tokens",
|
||||||
|
json={"paths": ["hello.txt"], "mode": "ro", "name": "public-share"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json
|
||||||
|
assert data["kind"] == "share"
|
||||||
|
assert data["username"] == ""
|
||||||
|
assert data["sso_user_id"] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user