Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
232fd92b22 | ||
|
|
be69164c8f | ||
|
|
4f39875786 | ||
|
|
21250a1a2d | ||
|
|
849b1a6868 | ||
|
|
7be02e951d | ||
|
|
bb38328c24 | ||
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf |
+1
-1
@@ -4,5 +4,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/cista/_version.py
|
/cista/_version.py
|
||||||
/cista/wwwroot/*
|
/cista/frontend-build/
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
+7
-5
@@ -42,13 +42,17 @@ Options:
|
|||||||
--import-droppy Import Droppy config from ~/.droppy/config
|
--import-droppy Import Droppy config from ~/.droppy/config
|
||||||
--dev Developer mode (reloads, friendlier crashes, more logs)
|
--dev Developer mode (reloads, friendlier crashes, more logs)
|
||||||
|
|
||||||
Listen address, path and imported options are preserved in config, and only
|
Listen address and path are preserved in config,
|
||||||
custom config dir and dev mode need to be specified on subsequent runs.
|
and only config dir and dev mode need to be specified on subsequent runs.
|
||||||
|
|
||||||
User management:
|
User management:
|
||||||
--user NAME Create or modify user
|
--user NAME Create or modify user
|
||||||
--privileged Give the user full admin rights
|
--privileged Give the user full admin rights
|
||||||
--password Reset password
|
--password Reset password
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
||||||
|
https://git.zi.fi/leovasanko/paskia
|
||||||
"""
|
"""
|
||||||
|
|
||||||
first_time_help = """\
|
first_time_help = """\
|
||||||
@@ -107,6 +111,7 @@ def _main():
|
|||||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
||||||
)
|
)
|
||||||
settings = droppy.readconf()
|
settings = droppy.readconf()
|
||||||
|
# Droppy's public flag is kept as-is (same name in our config)
|
||||||
if path:
|
if path:
|
||||||
settings["path"] = path
|
settings["path"] = path
|
||||||
elif not exists:
|
elif not exists:
|
||||||
@@ -115,9 +120,6 @@ def _main():
|
|||||||
settings["listen"] = listen
|
settings["listen"] = listen
|
||||||
elif not exists:
|
elif not exists:
|
||||||
settings["listen"] = ":8000"
|
settings["listen"] = ":8000"
|
||||||
if not exists and not import_droppy:
|
|
||||||
# We have no users, so make it public
|
|
||||||
settings["public"] = True
|
|
||||||
operation = config.update_config(settings)
|
operation = config.update_config(settings)
|
||||||
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
|
|||||||
+39
-10
@@ -3,9 +3,10 @@ import typing
|
|||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint, json
|
||||||
|
from sanic.exceptions import BadRequest
|
||||||
|
|
||||||
from cista import __version__, config, watching
|
from cista import __version__, auth, config, sso, watching
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
from cista.util.apphelpers import asend, websocket_wrapper
|
||||||
@@ -15,12 +16,12 @@ fileserver = FileServer()
|
|||||||
|
|
||||||
|
|
||||||
@bp.before_server_start
|
@bp.before_server_start
|
||||||
async def start_fileserver(app, _):
|
async def start_fileserver(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):
|
||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +93,23 @@ async def control(req, ws):
|
|||||||
@bp.websocket("watch")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
|
# Build user info from either built-in auth or SSO
|
||||||
|
user_info = None
|
||||||
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
|
# SSO auth (paskia mode): extract from validation response
|
||||||
|
ctx = sso_user.get("ctx", {})
|
||||||
|
perms = ctx.get("permissions", [])
|
||||||
|
user_info = {
|
||||||
|
"username": ctx.get("user", {}).get("display_name", ""),
|
||||||
|
"privileged": "cista:admin" in perms,
|
||||||
|
}
|
||||||
|
elif req.ctx.user:
|
||||||
|
# Built-in auth: use local user database
|
||||||
|
user_info = {
|
||||||
|
"username": req.ctx.username,
|
||||||
|
"privileged": req.ctx.user.privileged,
|
||||||
|
}
|
||||||
|
|
||||||
await ws.send(
|
await ws.send(
|
||||||
msgspec.json.encode(
|
msgspec.json.encode(
|
||||||
{
|
{
|
||||||
@@ -99,13 +117,9 @@ async def watch(req, ws):
|
|||||||
"name": config.config.name or config.config.path.name,
|
"name": config.config.name or config.config.path.name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"public": config.config.public,
|
"public": config.config.public,
|
||||||
|
"paskia": sso.paskia_enabled(),
|
||||||
},
|
},
|
||||||
"user": {
|
"user": user_info,
|
||||||
"username": req.ctx.username,
|
|
||||||
"privileged": req.ctx.user.privileged,
|
|
||||||
}
|
|
||||||
if req.ctx.user
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
).decode()
|
).decode()
|
||||||
)
|
)
|
||||||
@@ -136,3 +150,18 @@ def subscribe(uuid, ws):
|
|||||||
watching.format_space(watching.state.space),
|
watching.format_space(watching.state.space),
|
||||||
watching.format_root(watching.state.root),
|
watching.format_root(watching.state.root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/public")
|
||||||
|
async def update_public(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
public = request.json["public"]
|
||||||
|
if not isinstance(public, bool):
|
||||||
|
raise ValueError("public must be a boolean")
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing public field") from None
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(str(e)) from None
|
||||||
|
config.update_config({"public": public})
|
||||||
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|||||||
+32
-10
@@ -18,7 +18,7 @@ from setproctitle import setproctitle
|
|||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, watching
|
from cista import auth, config, preview, session, sso, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
@@ -26,7 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception
|
|||||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.blueprint(auth.bp)
|
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||||
|
else:
|
||||||
|
app.blueprint(auth.bp) # Built-in auth routes
|
||||||
app.blueprint(preview.bp)
|
app.blueprint(preview.bp)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
@@ -36,22 +40,23 @@ setproctitle("cista-main")
|
|||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def main_start(app, loop):
|
async def main_start(app):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
setproctitle(f"cista {config.config.path.name}")
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
workers = max(2, min(8, cpu_count()))
|
workers = max(2, min(8, cpu_count()))
|
||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
max_workers=workers, thread_name_prefix="cista-ioworker"
|
max_workers=workers, thread_name_prefix="cista-ioworker"
|
||||||
)
|
)
|
||||||
watching.start(app, loop)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
# 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, loop):
|
async def main_stop(app):
|
||||||
quit.set()
|
quit.set()
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
|
await sso.close_client()
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
@@ -74,10 +79,23 @@ async def use_session(req):
|
|||||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_response
|
||||||
|
async def forward_sso_cookies(req, res):
|
||||||
|
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||||
|
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||||
|
for cookie in cookies:
|
||||||
|
res.headers.add("set-cookie", cookie)
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
def http_fileserver(app, _):
|
def http_fileserver(app):
|
||||||
bp = Blueprint("fileserver")
|
bp = Blueprint("fileserver")
|
||||||
bp.on_request(auth.verify)
|
|
||||||
|
@bp.on_request
|
||||||
|
async def verify_fileserver(request):
|
||||||
|
"""Verify access to file server routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
bp.static(
|
bp.static(
|
||||||
"/files/",
|
"/files/",
|
||||||
config.config.path,
|
config.config.path,
|
||||||
@@ -93,9 +111,9 @@ www = {}
|
|||||||
|
|
||||||
def _load_wwwroot(www):
|
def _load_wwwroot(www):
|
||||||
wwwnew = {}
|
wwwnew = {}
|
||||||
base = Path(__file__).with_name("wwwroot")
|
base = Path(__file__).with_name("frontend-build")
|
||||||
paths = [PurePath()]
|
paths = [PurePath()]
|
||||||
zstd = ZstdCompressor(level=10)
|
zstd = ZstdCompressor(level=18)
|
||||||
while paths:
|
while paths:
|
||||||
path = paths.pop(0)
|
path = paths.pop(0)
|
||||||
current = base / path
|
current = base / path
|
||||||
@@ -211,7 +229,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):
|
||||||
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||||
return redirect("/assets/logo-97d1d7eb.svg", status=308)
|
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
||||||
|
|
||||||
|
|
||||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||||
@@ -239,6 +257,10 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|||||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
async def zip_download(req, keys, zipfile, ext):
|
||||||
"""Download a zip archive of the given keys"""
|
"""Download a zip archive of the given keys"""
|
||||||
|
if config.config.authentication == "paskia":
|
||||||
|
await auth.verify_sso(req)
|
||||||
|
else:
|
||||||
|
auth.verify(req)
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(wanted)
|
||||||
|
|||||||
+264
-60
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
|||||||
from cista import config, session
|
from cista import config, session
|
||||||
from cista.util import pwgen
|
from cista.util import pwgen
|
||||||
|
|
||||||
|
_LOGIN_PAGE_CSS = """\
|
||||||
|
/* ===========================================
|
||||||
|
LOGIN PAGE STYLES
|
||||||
|
Must match ModalDialog.vue global styles.
|
||||||
|
=========================================== */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: #ddd;
|
||||||
|
color: #000;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0 1rem #0008;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
color: #444;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
.button-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #f80; }
|
||||||
|
button:disabled {
|
||||||
|
background: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #c00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LOGIN_PAGE_JS = """\
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const usernameField = document.getElementById('username');
|
||||||
|
const passwordField = document.getElementById('password');
|
||||||
|
const isInIframe = window.parent !== window;
|
||||||
|
|
||||||
|
// Focus username field on load
|
||||||
|
usernameField.focus();
|
||||||
|
|
||||||
|
const showError = (msg) => {
|
||||||
|
error.textContent = msg;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Log in';
|
||||||
|
// Focus and select the relevant field
|
||||||
|
if (msg.toLowerCase().includes('password')) {
|
||||||
|
passwordField.focus();
|
||||||
|
passwordField.select();
|
||||||
|
} else {
|
||||||
|
usernameField.focus();
|
||||||
|
usernameField.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
error.textContent = '';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Logging in...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameField.value,
|
||||||
|
password: passwordField.value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (isInIframe) {
|
||||||
|
window.parent.postMessage({type: 'auth-success'}, '*');
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showError(data.message || data.detail || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Connection error. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import for SSO validation (lazily loaded to avoid circular imports)
|
||||||
|
_sso_module = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sso():
|
||||||
|
global _sso_module
|
||||||
|
if _sso_module is None:
|
||||||
|
from cista import sso
|
||||||
|
|
||||||
|
_sso_module = sso
|
||||||
|
return _sso_module
|
||||||
|
|
||||||
|
|
||||||
_argon = argon2.PasswordHasher()
|
_argon = argon2.PasswordHasher()
|
||||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||||
|
|
||||||
@@ -63,62 +231,109 @@ class LoginResponse(msgspec.Struct):
|
|||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
def verify(request, *, privileged=False):
|
async def verify(request, *, privileged=False):
|
||||||
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
"""Verify that the request is authorized.
|
||||||
if privileged:
|
|
||||||
if request.ctx.user:
|
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||||
if request.ctx.user.privileged:
|
For built-in mode, checks session-based authentication.
|
||||||
return
|
For public mode (config.public=True), allows all requests.
|
||||||
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
|
||||||
elif config.config.public or request.ctx.user:
|
All 401/403 responses include auth.iframe URL for consistent frontend handling
|
||||||
|
via the paskia library's showAuthIframe().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Sanic request object
|
||||||
|
privileged: If True, requires admin privileges
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Unauthorized: If authentication is required
|
||||||
|
Forbidden: If access is denied
|
||||||
|
"""
|
||||||
|
sso = _get_sso()
|
||||||
|
if sso.paskia_enabled():
|
||||||
|
# SSO validation against auth backend
|
||||||
|
# Always check cista:login; privileged flag comes from response perm list
|
||||||
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
return
|
return
|
||||||
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
|
|
||||||
|
user = getattr(request.ctx, "user", None)
|
||||||
|
if privileged:
|
||||||
|
if user:
|
||||||
|
if user.privileged:
|
||||||
|
return
|
||||||
|
raise Forbidden(
|
||||||
|
"Access Forbidden: Only for privileged users",
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
elif config.config.public or user:
|
||||||
|
return
|
||||||
|
# Return iframe URL for paskia library to show login dialog
|
||||||
|
raise Unauthorized(
|
||||||
|
f"Login required for {request.path}",
|
||||||
|
"cookie",
|
||||||
|
context={"auth": {"iframe": "/auth/restricted"}},
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("auth")
|
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
|
||||||
|
bp = Blueprint("auth", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/login")
|
@bp.get("/restricted")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
doc = Document("Cista Login")
|
"""Login page that works both standalone and in paskia iframe."""
|
||||||
with doc.div(id="login"):
|
s = session.get(request)
|
||||||
with doc.form(method="POST", autocomplete="on"):
|
|
||||||
doc.h1("Login")
|
# Check if already logged in
|
||||||
doc.input(
|
if s:
|
||||||
name="username",
|
# Already authenticated - signal success if in iframe
|
||||||
placeholder="Username",
|
return html(_login_success_page(s["username"]))
|
||||||
autocomplete="username",
|
|
||||||
required=True,
|
doc = Document("Cista - Login")
|
||||||
).br
|
# Add paskia-compatible styling and scripts
|
||||||
doc.input(
|
doc.style(_LOGIN_PAGE_CSS)
|
||||||
type="password",
|
with doc.div(class_="login-card"):
|
||||||
name="password",
|
doc.h1("Authentication Required")
|
||||||
placeholder="Password",
|
with doc.div(class_="content"):
|
||||||
autocomplete="current-password",
|
with doc.form(method="POST", id="loginForm", autocomplete="on"):
|
||||||
required=True,
|
doc.label("Username:", for_="username")
|
||||||
).br
|
doc.input(
|
||||||
doc.input(type="submit", value="Login")
|
type="text",
|
||||||
s = session.get(request)
|
id="username",
|
||||||
if s:
|
name="username",
|
||||||
name = s["username"]
|
autocomplete="username webauthn",
|
||||||
with doc.form(method="POST", action="/logout"):
|
required=True,
|
||||||
doc.input(type="submit", value=f"Logout {name}")
|
)
|
||||||
flash = request.cookies.message
|
doc.label("Password:", for_="password")
|
||||||
if flash:
|
doc.input(
|
||||||
doc.dialog(
|
type="password",
|
||||||
flash,
|
id="password",
|
||||||
id="flash",
|
name="password",
|
||||||
open=True,
|
autocomplete="current-password webauthn",
|
||||||
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
|
required=True,
|
||||||
)
|
)
|
||||||
|
with doc.div(class_="button-row"):
|
||||||
|
doc.button("Log in", type="submit", id="submitBtn")
|
||||||
|
doc.p("", class_="error", id="error")
|
||||||
|
|
||||||
|
# JavaScript for AJAX login and postMessage communication
|
||||||
|
doc.script_(_LOGIN_PAGE_JS)
|
||||||
|
|
||||||
res = html(doc)
|
res = html(doc)
|
||||||
if flash:
|
|
||||||
res.cookies.delete_cookie("flash")
|
|
||||||
if s is False:
|
if s is False:
|
||||||
session.delete(res)
|
session.delete(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def _login_success_page(username: str) -> str:
|
||||||
|
"""Minimal page that signals auth-success to parent iframe."""
|
||||||
|
return str(
|
||||||
|
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/login")
|
@bp.post("/login")
|
||||||
async def login_post(request):
|
async def login_post(request):
|
||||||
try:
|
try:
|
||||||
@@ -149,7 +364,7 @@ async def login_post(request):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/api/logout")
|
||||||
async def logout_post(request):
|
async def logout_post(request):
|
||||||
s = request.ctx.session
|
s = request.ctx.session
|
||||||
msg = "Logged out" if s else "Not logged in"
|
msg = "Logged out" if s else "Not logged in"
|
||||||
@@ -196,7 +411,7 @@ async def change_password(request):
|
|||||||
|
|
||||||
@bp.get("/users")
|
@bp.get("/users")
|
||||||
async def list_users(request):
|
async def list_users(request):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
users = []
|
users = []
|
||||||
for name, user in config.config.users.items():
|
for name, user in config.config.users.items():
|
||||||
users.append(
|
users.append(
|
||||||
@@ -211,7 +426,7 @@ async def list_users(request):
|
|||||||
|
|
||||||
@bp.post("/users")
|
@bp.post("/users")
|
||||||
async def create_user(request):
|
async def create_user(request):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
if request.headers.content_type == "application/json":
|
if request.headers.content_type == "application/json":
|
||||||
username = request.json["username"]
|
username = request.json["username"]
|
||||||
@@ -240,7 +455,7 @@ async def create_user(request):
|
|||||||
|
|
||||||
@bp.put("/users/<username>")
|
@bp.put("/users/<username>")
|
||||||
async def update_user(request, username):
|
async def update_user(request, username):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
try:
|
try:
|
||||||
if request.headers.content_type == "application/json":
|
if request.headers.content_type == "application/json":
|
||||||
changes = request.json
|
changes = request.json
|
||||||
@@ -273,7 +488,7 @@ async def update_user(request, username):
|
|||||||
|
|
||||||
@bp.delete("/users/<username>")
|
@bp.delete("/users/<username>")
|
||||||
async def delete_user(request, username):
|
async def delete_user(request, username):
|
||||||
verify(request, privileged=True)
|
await verify(request, privileged=True)
|
||||||
if username not in config.config.users:
|
if username not in config.config.users:
|
||||||
raise BadRequest("User does not exist")
|
raise BadRequest("User does not exist")
|
||||||
try:
|
try:
|
||||||
@@ -281,14 +496,3 @@ async def delete_user(request, username):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BadRequest(str(e)) from e
|
raise BadRequest(str(e)) from e
|
||||||
return json({"message": f"User {username} deleted"})
|
return json({"message": f"User {username} deleted"})
|
||||||
|
|
||||||
|
|
||||||
@bp.put("/config/public")
|
|
||||||
async def update_public(request):
|
|
||||||
verify(request, privileged=True)
|
|
||||||
try:
|
|
||||||
public = request.json["public"]
|
|
||||||
except KeyError:
|
|
||||||
raise BadRequest("Missing public field") from None
|
|
||||||
config.update_config({"public": public})
|
|
||||||
return json({"message": "Public setting updated"})
|
|
||||||
|
|||||||
+9
-1
@@ -152,7 +152,15 @@ def modifies_config(
|
|||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
init_confdir()
|
init_confdir()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
raw = conffile.read_bytes()
|
||||||
|
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
|
||||||
|
# Migrate from old authentication field if present
|
||||||
|
raw_dict = msgspec.toml.decode(raw)
|
||||||
|
if "authentication" in raw_dict and "public" not in raw_dict:
|
||||||
|
# Old config with authentication mode: migrate to public bool
|
||||||
|
new_public = raw_dict["authentication"] == "none"
|
||||||
|
config = msgspec.structs.replace(config, public=new_public)
|
||||||
|
update_config({}) # Save the migrated config
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
|
|||||||
+71
-41
@@ -13,17 +13,24 @@ import fitz # PyMuPDF
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pillow_heif
|
import pillow_heif
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sanic import Blueprint, empty, raw
|
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 config
|
from cista import auth, config
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
pillow_heif.register_heif_opener()
|
pillow_heif.register_heif_opener()
|
||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.on_request
|
||||||
|
async def verify_preview(request):
|
||||||
|
"""Verify access to preview routes."""
|
||||||
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||||
EXIF_ORI = {
|
EXIF_ORI = {
|
||||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||||
@@ -43,12 +50,12 @@ async def preview(req, path):
|
|||||||
maxzoom = float(req.args.get("zoom", 2.0))
|
maxzoom = float(req.args.get("zoom", 2.0))
|
||||||
quality = int(req.args.get("q", 60))
|
quality = int(req.args.get("q", 60))
|
||||||
rel = PurePosixPath(sanitize(unquote(path)))
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
path = config.config.path / rel
|
filepath = config.config.path / rel
|
||||||
stat = path.lstat()
|
stat = filepath.lstat()
|
||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
savename = PurePosixPath(path.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": format_date_time(stat.st_mtime),
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
@@ -61,22 +68,30 @@ async def preview(req, path):
|
|||||||
# The client has it cached, respond 304 Not Modified
|
# The client has it cached, respond 304 Not Modified
|
||||||
return empty(304, headers=headers)
|
return empty(304, headers=headers)
|
||||||
|
|
||||||
if not path.is_file():
|
if not filepath.is_file():
|
||||||
raise NotFound("File not found")
|
raise NotFound("File not found")
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
)
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
def dispatch(path, quality, maxsize, maxzoom):
|
||||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
try:
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||||
type, _ = mimetypes.guess_type(path.name)
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
if type and type.startswith("video/"):
|
type, _ = mimetypes.guess_type(path.name)
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
if type and type.startswith("video/"):
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error generating preview for {path.name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
@@ -121,7 +136,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
w, h = page.rect[2:4]
|
w, h = page.rect[2:4]
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
mat = fitz.Matrix(zoom, zoom)
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]
|
pix = page.get_pixmap(matrix=mat)
|
||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
@@ -166,35 +181,49 @@ def process_video(path, *, maxsize, quality):
|
|||||||
new_height = int(frame.height * scale_factor)
|
new_height = int(frame.height * scale_factor)
|
||||||
frame = frame.reformat(width=new_width, height=new_height)
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
# Simple rotation detection and logging
|
# Apply EXIF rotation if present
|
||||||
if frame.rotation:
|
if frame.rotation:
|
||||||
try:
|
# frame.rotation indicates clockwise rotation needed to display correctly
|
||||||
fplanes = frame.to_ndarray()
|
# np.rot90 rotates counter-clockwise, so we negate k
|
||||||
# Split into Y, U, V planes of proper dimensions
|
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
|
||||||
planes = [
|
if k == 2:
|
||||||
fplanes[: frame.height],
|
# 180° rotation can be done in YUV420p, preserving HDR
|
||||||
fplanes[frame.height : frame.height + frame.height // 4].reshape(
|
try:
|
||||||
frame.height // 2, frame.width // 2
|
fplanes = frame.to_ndarray()
|
||||||
),
|
# Split into Y, U, V planes of proper dimensions
|
||||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
planes = [
|
||||||
frame.height // 2, frame.width // 2
|
fplanes[: frame.height],
|
||||||
),
|
fplanes[
|
||||||
]
|
frame.height : frame.height + frame.height // 4
|
||||||
# Rotate
|
].reshape(frame.height // 2, frame.width // 2),
|
||||||
planes = [np.rot90(p, frame.rotation // 90) for p in planes]
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
# Restore PyAV format
|
frame.height // 2, frame.width // 2
|
||||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
),
|
||||||
-1, planes[0].shape[1]
|
]
|
||||||
)
|
# Rotate each plane by 180°
|
||||||
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
planes = [np.rot90(p, 2) for p in planes]
|
||||||
del planes, fplanes
|
# Restore PyAV format
|
||||||
except Exception as e:
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
if "not yet supported" in str(e):
|
-1, planes[0].shape[1]
|
||||||
logger.warning(
|
)
|
||||||
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
|
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}"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.exception(f"Error rotating video frame: {e}")
|
|
||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
@@ -211,6 +240,7 @@ def process_video(path, *, maxsize, quality):
|
|||||||
assert isinstance(ostream, av.VideoStream)
|
assert isinstance(ostream, av.VideoStream)
|
||||||
ostream.width = frame.width
|
ostream.width = frame.width
|
||||||
ostream.height = frame.height
|
ostream.height = frame.height
|
||||||
|
ostream.pix_fmt = frame.format.name
|
||||||
icc = istream.codec_context
|
icc = istream.codec_context
|
||||||
occ = ostream.codec_context
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
|||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
"""SSO (paskia) authentication proxy and validation module.
|
||||||
|
|
||||||
|
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
|
||||||
|
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
|
||||||
|
- All /auth/* requests are proxied to the paskia backend
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
|
||||||
|
Must include scheme (http/https), no trailing slash
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from sanic import Blueprint
|
||||||
|
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
# Auth backend URL for SSO validation (from env, no trailing slash)
|
||||||
|
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
# Validate and set PASKIA_BACKEND_URL
|
||||||
|
if _raw_url:
|
||||||
|
if not re.match(r"^https?://[^\s/]+$", _raw_url):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
|
||||||
|
"must be http(s)://host[:port] with no path or trailing slash"
|
||||||
|
)
|
||||||
|
PASKIA_BACKEND_URL = _raw_url
|
||||||
|
else:
|
||||||
|
PASKIA_BACKEND_URL = ""
|
||||||
|
|
||||||
|
|
||||||
|
def paskia_enabled() -> bool:
|
||||||
|
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
|
||||||
|
return bool(PASKIA_BACKEND_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared httpx client for SSO requests (reused for connection pooling)
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client() -> httpx.AsyncClient:
|
||||||
|
"""Get or create the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is None or _client.is_closed:
|
||||||
|
_client = httpx.AsyncClient(timeout=1.0)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client():
|
||||||
|
"""Close the shared httpx client."""
|
||||||
|
global _client
|
||||||
|
if _client is not None and not _client.is_closed:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
|
||||||
|
"""Validate an SSO request against the auth backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Sanic request object
|
||||||
|
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if valid, None if validation fails with auth required response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Forbidden: If access is denied (403)
|
||||||
|
Unauthorized: If authentication is required (401)
|
||||||
|
"""
|
||||||
|
if not paskia_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if "host" in request.headers:
|
||||||
|
headers["host"] = request.headers["host"]
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
headers["authorization"] = request.headers["authorization"]
|
||||||
|
headers["accept"] = "application/json"
|
||||||
|
headers["x-forwarded-for"] = request.client_ip
|
||||||
|
headers["x-forwarded-host"] = request.host
|
||||||
|
headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
request.ctx.sso_user = data
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
request.ctx.sso_user = {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
error_data = {"detail": response.text or "Authentication error"}
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
if "auth" in error_data and "iframe" in error_data["auth"]:
|
||||||
|
error_data["auth"]["iframe"] += "&theme=light"
|
||||||
|
raise Unauthorized(
|
||||||
|
error_data.get("detail", "Authentication required"),
|
||||||
|
"cookie",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
elif response.status_code == 403:
|
||||||
|
raise Forbidden(
|
||||||
|
error_data.get("detail", "Access denied"),
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = error_data.get("detail", "")
|
||||||
|
logger.warning(
|
||||||
|
f"SSO validation {url} returned {response.status_code}: {detail}"
|
||||||
|
)
|
||||||
|
raise Forbidden(
|
||||||
|
detail or "Authentication error",
|
||||||
|
context=error_data,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"SSO validation {url} network error: {e}")
|
||||||
|
raise SanicException(
|
||||||
|
"Authentication service unavailable",
|
||||||
|
status_code=502,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_request(request):
|
||||||
|
"""Proxy a request to the auth backend.
|
||||||
|
|
||||||
|
All requests under /auth/ are proxied when paskia mode is enabled.
|
||||||
|
"""
|
||||||
|
client = await get_client()
|
||||||
|
|
||||||
|
path = request.path
|
||||||
|
query_string = request.query_string
|
||||||
|
url = f"{PASKIA_BACKEND_URL}{path}"
|
||||||
|
if query_string:
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
skip_headers = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
"proxy-authorization",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in request.headers.items()
|
||||||
|
if key.lower() not in skip_headers
|
||||||
|
]
|
||||||
|
headers.append(("x-forwarded-for", request.client_ip))
|
||||||
|
headers.append(("x-forwarded-host", request.host))
|
||||||
|
headers.append(("x-forwarded-proto", request.scheme))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
content=request.body if request.body else None,
|
||||||
|
) as response:
|
||||||
|
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||||
|
|
||||||
|
resp_hop_by_hop = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in response.headers.multi_items()
|
||||||
|
if key.lower() not in resp_hop_by_hop
|
||||||
|
]
|
||||||
|
|
||||||
|
from sanic import raw as raw_response
|
||||||
|
|
||||||
|
return raw_response(
|
||||||
|
raw_content,
|
||||||
|
status=response.status_code,
|
||||||
|
headers=resp_headers,
|
||||||
|
content_type=response.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Auth proxy request failed: {e}")
|
||||||
|
from sanic import json
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{"detail": "Authentication service unavailable", "error": str(e)},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_auth_websocket(request, ws):
|
||||||
|
"""Proxy a WebSocket connection to the auth backend."""
|
||||||
|
path = request.path
|
||||||
|
query_string = request.query_string
|
||||||
|
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
||||||
|
"https://", "wss://"
|
||||||
|
)
|
||||||
|
url = f"{ws_backend}{path}"
|
||||||
|
if query_string:
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
additional_headers = {}
|
||||||
|
if "cookie" in request.headers:
|
||||||
|
additional_headers["cookie"] = request.headers["cookie"]
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
additional_headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "origin" in request.headers:
|
||||||
|
additional_headers["origin"] = request.headers["origin"]
|
||||||
|
if "user-agent" in request.headers:
|
||||||
|
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||||
|
additional_headers["x-forwarded-for"] = request.ip
|
||||||
|
additional_headers["x-forwarded-host"] = request.host
|
||||||
|
additional_headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
url, additional_headers=additional_headers
|
||||||
|
) as backend_ws:
|
||||||
|
|
||||||
|
async def forward_to_backend():
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
await backend_ws.send(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
try:
|
||||||
|
async for message in backend_ws:
|
||||||
|
await ws.send(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
forward_to_backend(),
|
||||||
|
forward_to_client(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_websocket_request(request) -> bool:
|
||||||
|
"""Check if the request is a WebSocket upgrade request."""
|
||||||
|
connection = request.headers.get("connection", "").lower()
|
||||||
|
upgrade = request.headers.get("upgrade", "").lower()
|
||||||
|
connection_tokens = [t.strip() for t in connection.split(",")]
|
||||||
|
return "upgrade" in connection_tokens and upgrade == "websocket"
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_websocket_upgrade(request):
|
||||||
|
"""Handle WebSocket upgrade and proxy the connection."""
|
||||||
|
protocol = request.transport.get_protocol()
|
||||||
|
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
|
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||||
|
bp = Blueprint("sso", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route(
|
||||||
|
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||||
|
)
|
||||||
|
async def auth_proxy(request, path=""):
|
||||||
|
"""Proxy all auth requests to the auth backend."""
|
||||||
|
if _is_websocket_request(request):
|
||||||
|
await _handle_websocket_upgrade(request)
|
||||||
|
from sanic import empty
|
||||||
|
|
||||||
|
return empty()
|
||||||
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def auth_proxy_root(request):
|
||||||
|
"""Proxy root auth requests to the auth backend."""
|
||||||
|
if _is_websocket_request(request):
|
||||||
|
await _handle_websocket_upgrade(request)
|
||||||
|
from sanic import empty
|
||||||
|
|
||||||
|
return empty()
|
||||||
|
return await proxy_auth_request(request)
|
||||||
@@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
# Non-browsers get JSON errors
|
# Non-browsers get JSON errors
|
||||||
if "text/html" not in request.headers.accept:
|
if "text/html" not in request.headers.accept:
|
||||||
|
# Include auth context if present (for SSO auth required responses)
|
||||||
|
# Auth must be at top level for paskia library to detect it
|
||||||
|
response_data = {"code": code, "message": message, "detail": message, **context}
|
||||||
return jres(
|
return jres(
|
||||||
ErrorMsg({"code": code, "message": message, **context}),
|
response_data,
|
||||||
status=code,
|
status=code,
|
||||||
)
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
@@ -52,7 +55,7 @@ def websocket_wrapper(handler):
|
|||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
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"
|
||||||
|
|||||||
+2
-2
@@ -440,14 +440,14 @@ def watcher_poll(loop):
|
|||||||
quit.wait(0.1 + 8 * dur)
|
quit.wait(0.1 + 8 * dur)
|
||||||
|
|
||||||
|
|
||||||
def start(app, loop):
|
def start(app):
|
||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
use_inotify = sys.platform == "linux"
|
use_inotify = sys.platform == "linux"
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher_inotify if use_inotify else watcher_poll,
|
target=watcher_inotify if use_inotify else watcher_poll,
|
||||||
args=[loop],
|
args=[app.loop],
|
||||||
# Descriptive name for system monitoring
|
# Descriptive name for system monitoring
|
||||||
name=f"cista-watcher {rootpath}",
|
name=f"cista-watcher {rootpath}",
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang=en>
|
<html lang=en>
|
||||||
<meta charset=UTF-8>
|
<meta charset=UTF-8>
|
||||||
<title>Cista Storage</title>
|
<title>Cista Storage</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
|
||||||
<link rel="icon" href="/src/assets/logo.svg">
|
<link rel="icon" href="/src/assets/logo.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
+31
-30
@@ -16,39 +16,40 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||||
"@vueuse/core": "^10.4.1",
|
"@vueuse/core": "^14.1.0",
|
||||||
"esbuild": "^0.19.5",
|
"esbuild": "^0.27.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.23",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.23",
|
||||||
"pinia": "^2.1.6",
|
"paskia": "^0.1.2",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia": "^3.0.4",
|
||||||
"unplugin-vue-components": "^0.25.2",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"vite-svg-loader": "^4.0.0",
|
"unplugin-vue-components": "^31.0.0",
|
||||||
"vue": "^3.3.4",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue-router": "^4.2.4"
|
"vue": "^3.5.27",
|
||||||
|
"vue-router": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.3.3",
|
"@rushstack/eslint-patch": "^1.15.0",
|
||||||
"@tsconfig/node18": "^18.2.2",
|
"@tsconfig/node18": "^18.2.6",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/lodash-es": "^4.17.10",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^18.17.17",
|
"@types/node": "^25.1.0",
|
||||||
"@vitejs/plugin-vue": "^4.3.4",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/test-utils": "^2.4.1",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"eslint": "^8.52.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-vue": "^9.18.1",
|
"eslint-plugin-vue": "^10.7.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^27.4.0",
|
||||||
"npm-run-all2": "^6.0.6",
|
"npm-run-all2": "^8.0.4",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.2.0",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^4.4.9",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^0.34.4",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^1.8.11"
|
"vue-tsc": "^3.2.4"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"semi": false,
|
"semi": false,
|
||||||
|
|||||||
+27
-3
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginModal />
|
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<HeaderSelected :path="path.pathList" />
|
||||||
@@ -30,6 +33,7 @@ import Router from '@/router/index'
|
|||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
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 AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@@ -39,10 +43,10 @@ interface Path {
|
|||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
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 pathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
return {
|
return {
|
||||||
path: p[0],
|
path: p[0] ?? '',
|
||||||
pathList,
|
pathList,
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
@@ -158,3 +162,23 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Toast notifications - fixed at top center of viewport */
|
||||||
|
.toast-message {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
margin: 0 .5rem 0 1rem !important;
|
margin: 0 .5rem 0 1rem !important;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
|
position: static !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -165,6 +166,11 @@ body {
|
|||||||
font-family: 'Roboto';
|
font-family: 'Roboto';
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* Prevent any scrolling on body */
|
||||||
|
overflow: hidden;
|
||||||
|
/* Fallback for older browsers */
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
tbody .size,
|
tbody .size,
|
||||||
tbody .modified {
|
tbody .modified {
|
||||||
@@ -214,12 +220,14 @@ table {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
body#app {
|
body#app {
|
||||||
height: 100vh;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* Allow flex child to shrink below content size */
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -237,6 +245,7 @@ header nav.headermain {
|
|||||||
z-index: 101;
|
z-index: 101;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
@@ -248,9 +257,6 @@ header nav.headermain {
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
||||||
}
|
}
|
||||||
.modified [data-tooltip]:hover:after {
|
|
||||||
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
|
|
||||||
}
|
|
||||||
@keyframes appearbriefly {
|
@keyframes appearbriefly {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// SVG icon index - all icons bundled together
|
||||||
|
import AddFile from './add-file.svg'
|
||||||
|
import AddFolder from './add-folder.svg'
|
||||||
|
import Arrow from './arrow.svg'
|
||||||
|
import ArrowsH from './arrows-h.svg'
|
||||||
|
import ArrowsV from './arrows-v.svg'
|
||||||
|
import Check from './check.svg'
|
||||||
|
import Code from './code.svg'
|
||||||
|
import Cog from './cog.svg'
|
||||||
|
import Copy from './copy.svg'
|
||||||
|
import CreateFile from './create-file.svg'
|
||||||
|
import CreateFolder from './create-folder.svg'
|
||||||
|
import Cross from './cross.svg'
|
||||||
|
import Disk from './disk.svg'
|
||||||
|
import Download from './download.svg'
|
||||||
|
import Exclamation from './exclamation.svg'
|
||||||
|
import Eye from './eye.svg'
|
||||||
|
import Find from './find.svg'
|
||||||
|
import Fullscreen from './fullscreen.svg'
|
||||||
|
import Github from './github.svg'
|
||||||
|
import Home from './home.svg'
|
||||||
|
import Info from './info.svg'
|
||||||
|
import Link from './link.svg'
|
||||||
|
import Logo from './logo.svg'
|
||||||
|
import Loop from './loop.svg'
|
||||||
|
import Menu from './menu.svg'
|
||||||
|
import Next from './next.svg'
|
||||||
|
import Open from './open.svg'
|
||||||
|
import Paste from './paste.svg'
|
||||||
|
import Pause from './pause.svg'
|
||||||
|
import Pencil from './pencil.svg'
|
||||||
|
import Play from './play.svg'
|
||||||
|
import Plus from './plus.svg'
|
||||||
|
import Previous from './previous.svg'
|
||||||
|
import Reload from './reload.svg'
|
||||||
|
import Rename from './rename.svg'
|
||||||
|
import Scissors from './scissors.svg'
|
||||||
|
import Shuffle from './shuffle.svg'
|
||||||
|
import Signin from './signin.svg'
|
||||||
|
import Signout from './signout.svg'
|
||||||
|
import Skip from './skip.svg'
|
||||||
|
import Spinner from './spinner.svg'
|
||||||
|
import Stop from './stop.svg'
|
||||||
|
import Trash from './trash.svg'
|
||||||
|
import Triangle from './triangle.svg'
|
||||||
|
import Unfullscreen from './unfullscreen.svg'
|
||||||
|
import UpArrow from './up-arrow.svg'
|
||||||
|
import UploadCloud from './upload-cloud.svg'
|
||||||
|
import UserCog from './user-cog.svg'
|
||||||
|
import User from './user.svg'
|
||||||
|
import VolumeHigh from './volume-high.svg'
|
||||||
|
import VolumeLow from './volume-low.svg'
|
||||||
|
import VolumeMedium from './volume-medium.svg'
|
||||||
|
import VolumeMute from './volume-mute.svg'
|
||||||
|
import WindowCross from './window-cross.svg'
|
||||||
|
import Window from './window.svg'
|
||||||
|
import Wordwrap from './wordwrap.svg'
|
||||||
|
import Zoomin from './zoomin.svg'
|
||||||
|
import Zoomout from './zoomout.svg'
|
||||||
|
|
||||||
|
// Named exports for direct imports
|
||||||
|
export {
|
||||||
|
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
|
||||||
|
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
|
||||||
|
Disk, Download, Exclamation, Eye, Find, Fullscreen,
|
||||||
|
Github, Home, Info, Link, Logo, Loop, Menu,
|
||||||
|
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
|
||||||
|
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
|
||||||
|
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
|
||||||
|
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
|
||||||
|
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
|
||||||
|
Zoomin, Zoomout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||||
|
export const icons = {
|
||||||
|
'add-file': AddFile,
|
||||||
|
'add-folder': AddFolder,
|
||||||
|
'arrow': Arrow,
|
||||||
|
'arrows-h': ArrowsH,
|
||||||
|
'arrows-v': ArrowsV,
|
||||||
|
'check': Check,
|
||||||
|
'code': Code,
|
||||||
|
'cog': Cog,
|
||||||
|
'copy': Copy,
|
||||||
|
'create-file': CreateFile,
|
||||||
|
'create-folder': CreateFolder,
|
||||||
|
'cross': Cross,
|
||||||
|
'disk': Disk,
|
||||||
|
'download': Download,
|
||||||
|
'exclamation': Exclamation,
|
||||||
|
'eye': Eye,
|
||||||
|
'find': Find,
|
||||||
|
'fullscreen': Fullscreen,
|
||||||
|
'github': Github,
|
||||||
|
'home': Home,
|
||||||
|
'info': Info,
|
||||||
|
'link': Link,
|
||||||
|
'logo': Logo,
|
||||||
|
'loop': Loop,
|
||||||
|
'menu': Menu,
|
||||||
|
'next': Next,
|
||||||
|
'open': Open,
|
||||||
|
'paste': Paste,
|
||||||
|
'pause': Pause,
|
||||||
|
'pencil': Pencil,
|
||||||
|
'play': Play,
|
||||||
|
'plus': Plus,
|
||||||
|
'previous': Previous,
|
||||||
|
'reload': Reload,
|
||||||
|
'rename': Rename,
|
||||||
|
'scissors': Scissors,
|
||||||
|
'shuffle': Shuffle,
|
||||||
|
'signin': Signin,
|
||||||
|
'signout': Signout,
|
||||||
|
'skip': Skip,
|
||||||
|
'spinner': Spinner,
|
||||||
|
'stop': Stop,
|
||||||
|
'trash': Trash,
|
||||||
|
'triangle': Triangle,
|
||||||
|
'unfullscreen': Unfullscreen,
|
||||||
|
'up-arrow': UpArrow,
|
||||||
|
'upload-cloud': UploadCloud,
|
||||||
|
'user-cog': UserCog,
|
||||||
|
'user': User,
|
||||||
|
'volume-high': VolumeHigh,
|
||||||
|
'volume-low': VolumeLow,
|
||||||
|
'volume-medium': VolumeMedium,
|
||||||
|
'volume-mute': VolumeMute,
|
||||||
|
'window-cross': WindowCross,
|
||||||
|
'window': Window,
|
||||||
|
'wordwrap': Wordwrap,
|
||||||
|
'zoomin': Zoomin,
|
||||||
|
'zoomout': Zoomout,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
||||||
|
<div class="modal-dialog" id="accessdenied">
|
||||||
|
<div class="modal-content access-denied">
|
||||||
|
<p class="icon">⛔</p>
|
||||||
|
<p class="message">Access Denied</p>
|
||||||
|
<button @click="reload" class="button">Reload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { holdGlobalBackdrop } from 'paskia'
|
||||||
|
import { watchEffect } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backdrop active when this dialog shows
|
||||||
|
watchEffect(() => {
|
||||||
|
if (store.dialog === 'accessdenied') {
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.access-denied {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem !important;
|
||||||
|
}
|
||||||
|
.access-denied .icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
.access-denied .message {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -31,11 +31,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import home from '@/assets/svg/home.svg'
|
import { Home } from '@/assets/svg'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
|
const home = Home
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const links = [] as Array<HTMLElement>
|
const links = [] as Array<HTMLElement>
|
||||||
@@ -54,7 +55,7 @@ const isCurrent = (index: number) => index == props.path.length ? 'location' : u
|
|||||||
const focusCurrent = () => {
|
const focusCurrent = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const index = props.path.length
|
const index = props.path.length
|
||||||
if (index < links.length) links[index].focus()
|
if (index < links.length) links[index]!.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const navigate = (index: number) => {
|
|||||||
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 = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Global activation state - shared across all instances
|
||||||
|
let globalActive = false
|
||||||
|
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Track if we've seen real mouse movement (not touch-simulated)
|
||||||
|
let hasRealMouse = false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string
|
||||||
|
delay?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const mouseX = ref(0)
|
||||||
|
const mouseY = ref(0)
|
||||||
|
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastMoveX = 0
|
||||||
|
let lastMoveY = 0
|
||||||
|
|
||||||
|
// Movement threshold (pixels) - cursor must settle within this radius
|
||||||
|
const SETTLE_THRESHOLD = 8
|
||||||
|
|
||||||
|
const tooltipStyle = computed(() => ({
|
||||||
|
left: `${mouseX.value}px`,
|
||||||
|
top: `${mouseY.value}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Check if the device likely has a real mouse (fine pointer)
|
||||||
|
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
visible.value = true
|
||||||
|
globalActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTooltip = () => {
|
||||||
|
if (settleTimer) clearTimeout(settleTimer)
|
||||||
|
if (globalDeactivateTimer) {
|
||||||
|
clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = null
|
||||||
|
}
|
||||||
|
const delay = globalActive ? 0 : (props.delay ?? 900)
|
||||||
|
settleTimer = setTimeout(showTooltip, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startHover = (e: MouseEvent) => {
|
||||||
|
// Ignore touch events (no fine pointer and no confirmed real mouse)
|
||||||
|
if (!hasFinePointer() && !hasRealMouse) return
|
||||||
|
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = (e: MouseEvent) => {
|
||||||
|
// Detect real mouse via movement (touch events don't generate continuous mousemove)
|
||||||
|
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true
|
||||||
|
if (!hasFinePointer() && !hasRealMouse) return
|
||||||
|
|
||||||
|
mouseX.value = e.clientX
|
||||||
|
mouseY.value = e.clientY
|
||||||
|
|
||||||
|
// If tooltip is already visible, just update position
|
||||||
|
if (visible.value) return
|
||||||
|
|
||||||
|
const dx = e.clientX - lastMoveX
|
||||||
|
const dy = e.clientY - lastMoveY
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
// If cursor moved beyond threshold, reset settle timer
|
||||||
|
if (distance > SETTLE_THRESHOLD) {
|
||||||
|
lastMoveX = e.clientX
|
||||||
|
lastMoveY = e.clientY
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer)
|
||||||
|
settleTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule tooltip when cursor settles
|
||||||
|
if (!settleTimer) {
|
||||||
|
scheduleTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endHover = () => {
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer)
|
||||||
|
settleTimer = null
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
// Deactivate global state after a short delay if no new tooltip started
|
||||||
|
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
|
||||||
|
globalDeactivateTimer = setTimeout(() => {
|
||||||
|
globalActive = false
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
startHover,
|
||||||
|
updatePosition,
|
||||||
|
endHover,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cursor-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border-radius: 3rem 0 3rem 0;
|
||||||
|
box-shadow: 0 0 1rem var(--accent-color);
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
const writable = await fileHandle.createWritable()
|
const writable = await fileHandle.createWritable()
|
||||||
const url = `/files/${rel}`
|
const url = `/files/${rel}`
|
||||||
console.log('Fetching', url)
|
console.log('Fetching', url)
|
||||||
const res = await fetch(url)
|
const res = await apiFetch(url)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||||
@@ -144,7 +145,7 @@ const download = async () => {
|
|||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
store.error = "Single file via browser downloads"
|
store.error = "Single file via browser downloads"
|
||||||
return linkdl(`/files/${files[0][1]}`)
|
return linkdl(`/files/${files[0]![1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Use FileSystem API if multiple files and the browser supports it
|
||||||
if ('showDirectoryPicker' in window) {
|
if ('showDirectoryPicker' in window) {
|
||||||
@@ -163,7 +164,7 @@ const download = async () => {
|
|||||||
}
|
}
|
||||||
// Otherwise, zip and download
|
// Otherwise, zip and download
|
||||||
console.log("Falling back to zip download")
|
console.log("Falling back to zip download")
|
||||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download'
|
||||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||||
store.error = "Downloading as ZIP via browser downloads"
|
store.error = "Downloading as ZIP via browser downloads"
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||||
<component :is="cog" class="cog"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="!store.connected">No Connection</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
||||||
<p v-else-if="store.query">No matches!</p>
|
<p v-else-if="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
@@ -10,11 +11,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps } from 'vue'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import cog from '@/assets/svg/cog.svg'
|
import { Cog } from '@/assets/svg'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: string[],
|
path: string[],
|
||||||
@@ -35,4 +36,7 @@ svg.cog {
|
|||||||
filter: drop-shadow(0 0 1rem black);
|
filter: drop-shadow(0 0 1rem black);
|
||||||
fill: #888;
|
fill: #888;
|
||||||
}
|
}
|
||||||
|
svg.cog.stopped {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing :key=nowkey />
|
<FileModified :doc=editing :now=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
<td class="menu"></td>
|
<td class="menu"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=doc :key=nowkey />
|
<FileModified :doc=doc :now=nowkey />
|
||||||
<FileSize :doc=doc />
|
<FileSize :doc=doc />
|
||||||
<td class="menu">
|
<td class="menu">
|
||||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||||
@@ -175,7 +175,7 @@ defineExpose({
|
|||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
if (p === N) continue
|
if (p === N) continue
|
||||||
const key = docs[p].key
|
const key = docs[p]!.key
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
else store.selected.add(key)
|
else store.selected.add(key)
|
||||||
}
|
}
|
||||||
@@ -255,8 +255,8 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
const docloc = docs[i].loc
|
const docloc = docs[i]!.loc
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||||
}
|
}
|
||||||
const selectionIndeterminate = computed({
|
const selectionIndeterminate = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="modified right">
|
<td class="modified right">
|
||||||
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time>
|
<time
|
||||||
|
:datetime=datetime
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
|
>{{ modified }}</time>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { formatUnixDate } from '@/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
now: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
// Reference props.now to trigger reactivity when time updates
|
||||||
|
const modified = computed(() => {
|
||||||
|
props.now // trigger reactivity
|
||||||
|
return formatUnixDate(props.doc.mtime)
|
||||||
|
})
|
||||||
|
|
||||||
const datetime = computed(() =>
|
const datetime = computed(() =>
|
||||||
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
||||||
)
|
)
|
||||||
|
|
||||||
const tooltip = computed(() =>
|
const tooltipText = computed(() =>
|
||||||
datetime.value.replace('T', '\n').replace('Z', ' UTC')
|
datetime.value.replace('T', ' ').replace('Z', ' UTC')
|
||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
doc: Doc
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Doc } from '@/repositories/Document'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
return +unit ? "bytes" : unit
|
return +unit ? "bytes" : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
<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: mkdir, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<template v-if=showFolderBreadcrumb(index)>
|
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
||||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
</template>
|
|
||||||
</GalleryFigure>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,10 +51,12 @@ const rename = (doc: Doc, newName: string) => {
|
|||||||
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
|
||||||
}
|
}
|
||||||
const gallery = ref<HTMLElement>()
|
const gallery = ref<HTMLElement>()
|
||||||
const columns = computed(() => {
|
const columnCount = ref(1)
|
||||||
if (!gallery.value) return 1
|
const updateColumns = () => {
|
||||||
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
if (!gallery.value) return
|
||||||
})
|
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||||
|
}
|
||||||
|
const columns = computed(() => columnCount.value)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -131,7 +129,7 @@ defineExpose({
|
|||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
if (p === N) continue
|
if (p === N) continue
|
||||||
const key = docs[p].key
|
const key = docs[p]!.key
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
else store.selected.add(key)
|
else store.selected.add(key)
|
||||||
}
|
}
|
||||||
@@ -168,12 +166,21 @@ watchEffect(() => {
|
|||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
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.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||||
active.focus()
|
active.focus()
|
||||||
}
|
}
|
||||||
|
updateColumns()
|
||||||
|
if (gallery.value) {
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
const mkdir = (doc: Doc, name: string) => {
|
const mkdir = (doc: Doc, name: string) => {
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
@@ -202,9 +209,11 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
const docloc = docs[i].loc
|
const docloc = docs[i]!.loc
|
||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const selectionIndeterminate = computed({
|
const selectionIndeterminate = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
@@ -254,13 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
grid-template-rows: repeat(minmax(auto, 15em));
|
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.breadcrumb {
|
.folder-indicator {
|
||||||
border-radius: .5em 0 0 .5em;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.spacer {
|
.folder-start {
|
||||||
flex: 0 1000000000 4rem;
|
grid-column-start: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click=onclick
|
@click=onclick
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@@ -15,19 +18,24 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
<span>{{ doc.name }}</span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
|
<div class="tooltip-name">{{ doc.name }}</div>
|
||||||
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
@@ -40,6 +48,9 @@ const props = defineProps<{
|
|||||||
editing?: EditingProp,
|
editing?: EditingProp,
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const tooltipText = computed(() => props.doc.key)
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.tooltip-name {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tooltip-details {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -57,12 +75,15 @@ figure {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
figure > article {
|
figure > article {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
figure :deep(.video-container) {
|
||||||
|
height: 15em;
|
||||||
|
}
|
||||||
.titlespacer {
|
.titlespacer {
|
||||||
flex-shrink: 100000;
|
flex-shrink: 100000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain buttons">
|
<nav class="headermain buttons">
|
||||||
<template v-if="store.error">
|
|
||||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
|
||||||
<div class="smallgap"></div>
|
|
||||||
</template>
|
|
||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
@@ -30,14 +26,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref, nextTick, watchEffect } from 'vue'
|
import { ref, nextTick, watchEffect } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
|
import { showAuthIframe } from 'paskia'
|
||||||
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
const ssoStore = useSsoAuthStore()
|
||||||
const showSearchInput = ref<boolean>(false)
|
const showSearchInput = ref<boolean>(false)
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const searchButton = ref<HTMLButtonElement | null>()
|
const searchButton = ref<HTMLButtonElement | null>()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
@@ -73,14 +74,36 @@ watchEffect(() => {
|
|||||||
const settingsMenu = (e: Event) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
|
|
||||||
if (store.user.privileged) {
|
// For external auth, show user name as link to /auth/
|
||||||
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
|
items.push({
|
||||||
|
label: '👤 ' + (store.user.username || 'User Account'),
|
||||||
|
onClick: () => { window.location.href = '/auth/' }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show password change for non-SSO users
|
||||||
|
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||||
|
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.user.privileged) {
|
||||||
|
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||||
|
}
|
||||||
|
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||||
} else {
|
} else if (!ssoStore.isExternalAuth) {
|
||||||
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
// Show login in paskia iframe overlay
|
||||||
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
|
try {
|
||||||
|
await showAuthIframe('/auth/restricted')
|
||||||
|
resumeWatching()
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Login cancelled')
|
||||||
|
}
|
||||||
|
}})
|
||||||
}
|
}
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const op = (op: string, dst?: string) => {
|
|||||||
const msg = {
|
const msg = {
|
||||||
op,
|
op,
|
||||||
sel: sel.keys.map(key => {
|
sel: sel.keys.map(key => {
|
||||||
const doc = sel.docs[key]
|
const doc = sel.docs[key]!
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModalDialog name="login" title="Authentication required">
|
|
||||||
<form @submit.prevent="login">
|
|
||||||
<div class="login-container">
|
|
||||||
<label for="username">Username:</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
|
||||||
v-model="loginForm.username"
|
|
||||||
/>
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
spellcheck="false"
|
|
||||||
autocorrect="off"
|
|
||||||
required
|
|
||||||
v-model="loginForm.password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 class="error-text">
|
|
||||||
{{ loginForm.error || '\u00A0' }}
|
|
||||||
</h3>
|
|
||||||
<div class="dialog-buttons">
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<input id="submit" type="submit" value="Login" class="button-login" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ModalDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { loginUser } from '@/repositories/User'
|
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const loginForm = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
error: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
try {
|
|
||||||
loginForm.error = ''
|
|
||||||
confirmLoading.value = true
|
|
||||||
const msg = await loginUser(loginForm.username, loginForm.password)
|
|
||||||
store.login(msg.data.username, !!msg.data.privileged)
|
|
||||||
} catch (error) {
|
|
||||||
const httpError = error as ISimpleError
|
|
||||||
loginForm.error = httpError.message || '🛑 Unknown error'
|
|
||||||
} finally {
|
|
||||||
confirmLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-container {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.button-login {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--soft-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 0;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: .5rem 2rem;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all var(--transition-time) linear;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
<img v-else-if=doc.img :src=doc.url alt="">
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<div v-else-if=video() class="video-container">
|
||||||
|
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
|
<div class="play-overlay"><PlayIcon /></div>
|
||||||
|
</div>
|
||||||
<div v-else-if=audio() class="audio icon">
|
<div v-else-if=audio() class="audio icon">
|
||||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,6 +16,7 @@
|
|||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { Play as PlayIcon } from '@/assets/svg'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -42,7 +46,7 @@ const next = () => {
|
|||||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||||
for (const i in medias) {
|
for (const i in medias) {
|
||||||
if (medias[i] === (fscurrent || media.value)) {
|
if (medias[i] === (fscurrent || media.value)) {
|
||||||
el = medias[+i + 1] || medias[0]
|
el = medias[+i + 1] ?? medias[0] ?? null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,4 +169,43 @@ img::before {
|
|||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
content: '❌';
|
content: '❌';
|
||||||
}
|
}
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(.5em / 8);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 4em;
|
||||||
|
height: 4em;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.play-overlay svg {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
fill: white;
|
||||||
|
margin-left: 0.25em; /* Visual centering for play triangle */
|
||||||
|
}
|
||||||
|
.video-container:hover .play-overlay {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
video[data-playing] + .play-overlay {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
|
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
|
||||||
<h1 v-if="props.title">{{ props.title }}</h1>
|
<div class="modal-dialog" :id="props.name" ref="dialog">
|
||||||
<div>
|
<h1 v-if="props.title">{{ props.title }}</h1>
|
||||||
<slot>
|
<div class="modal-content">
|
||||||
Dialog with no content
|
<slot>
|
||||||
<button @click=close>OK</button>
|
Dialog with no content
|
||||||
</slot>
|
<button @click="close">OK</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watchEffect, nextTick } from 'vue'
|
import { ref, watchEffect, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
|
||||||
const dialog = ref<HTMLDialogElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
dialog.value!.close()
|
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
|
releaseGlobalBackdrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -29,62 +33,247 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
setTimeout(() => {
|
holdGlobalBackdrop()
|
||||||
dialog.value!.showModal()
|
nextTick(() => {
|
||||||
nextTick(() => {
|
overlay.value?.focus()
|
||||||
const input = dialog.value!.querySelector('input')
|
const input = dialog.value?.querySelector('input')
|
||||||
if (input) input.focus()
|
if (input) input.focus()
|
||||||
})
|
})
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
defineExpose({ show, close })
|
defineExpose({ show, close })
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (dialog.value) show()
|
if (overlay.value) {
|
||||||
|
overlay.value.focus()
|
||||||
|
const input = dialog.value?.querySelector('input')
|
||||||
|
if (input) input.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Style for the background */
|
/* ===========================================
|
||||||
dialog::backdrop {
|
MODAL DIALOG GLOBAL STYLES
|
||||||
content: '';
|
Shared styling for all modal dialogs.
|
||||||
display: block;
|
Login page (auth.py) has matching CSS.
|
||||||
|
=========================================== */
|
||||||
|
|
||||||
|
/* Overlay - covers entire viewport */
|
||||||
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
z-index: 1100;
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 100%;
|
align-items: center;
|
||||||
background: #0008;
|
justify-content: center;
|
||||||
backdrop-filter: blur(0.4em);
|
/* No backdrop - paskia handles that */
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the dialog by default */
|
/* Dialog container */
|
||||||
dialog[open] {
|
.modal-dialog {
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: black;
|
color: #000;
|
||||||
display: block;
|
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.2rem;
|
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0.2rem 0.2rem 1rem #000;
|
box-shadow: 0 0 1rem #0008;
|
||||||
padding: 1rem;
|
padding: 0;
|
||||||
position: fixed;
|
max-width: 90vw;
|
||||||
top: 0;
|
max-height: 90vh;
|
||||||
left: 0;
|
overflow: auto;
|
||||||
z-index: 1001;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
font: inherit;
|
/* Dialog title bar */
|
||||||
}
|
.modal-dialog > h1 {
|
||||||
dialog[open] > h1 {
|
background: #146;
|
||||||
background: var(--soft-color);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
font-weight: normal;
|
||||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog[open] > div {
|
/* Dialog content area */
|
||||||
padding: 1em 0;
|
.modal-dialog > .modal-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headings inside dialog */
|
||||||
|
.modal-dialog h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.modal-dialog h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.modal-dialog a {
|
||||||
|
color: #146;
|
||||||
|
}
|
||||||
|
.modal-dialog a:hover {
|
||||||
|
color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.modal-dialog input[type="text"],
|
||||||
|
.modal-dialog input[type="password"],
|
||||||
|
.modal-dialog select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog input[type="text"]:focus,
|
||||||
|
.modal-dialog input[type="password"]:focus,
|
||||||
|
.modal-dialog select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.modal-dialog label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.modal-dialog button,
|
||||||
|
.modal-dialog input[type="submit"],
|
||||||
|
.modal-dialog input[type="reset"],
|
||||||
|
.modal-dialog .button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:hover,
|
||||||
|
.modal-dialog input[type="submit"]:hover,
|
||||||
|
.modal-dialog input[type="reset"]:hover,
|
||||||
|
.modal-dialog .button:hover {
|
||||||
|
background: #f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog button:disabled,
|
||||||
|
.modal-dialog input[type="submit"]:disabled,
|
||||||
|
.modal-dialog input[type="reset"]:disabled,
|
||||||
|
.modal-dialog .button:disabled {
|
||||||
|
background: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small button variant */
|
||||||
|
.modal-dialog .button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger button variant */
|
||||||
|
.modal-dialog .button.danger {
|
||||||
|
background: #c00;
|
||||||
|
}
|
||||||
|
.modal-dialog .button.danger:hover:not(:disabled) {
|
||||||
|
background: #f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form row layout (label + input side by side) */
|
||||||
|
.modal-dialog .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form grid for multiple label+input pairs */
|
||||||
|
.modal-dialog .form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog button row (footer) */
|
||||||
|
.modal-dialog .dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error text */
|
||||||
|
.modal-dialog .error-text {
|
||||||
|
color: #c00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.modal-dialog .success-message {
|
||||||
|
background: #f80;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data tables inside dialogs */
|
||||||
|
.modal-dialog table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th,
|
||||||
|
.modal-dialog td {
|
||||||
|
border: 1px solid #888;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog th {
|
||||||
|
background: #146;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog td {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox alignment in tables */
|
||||||
|
.modal-dialog td input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraph text */
|
||||||
|
.modal-dialog p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.modal-dialog .loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<form>
|
<form>
|
||||||
<template v-if="store.user.isLoggedIn">
|
<template v-if="store.user.isLoggedIn">
|
||||||
<h3>Update your authentication</h3>
|
<h3>Update your authentication</h3>
|
||||||
<div class="login-container">
|
<div class="form-grid">
|
||||||
<label for="username">New password:</label>
|
<label for="passwordChange">New password:</label>
|
||||||
<input
|
<input
|
||||||
ref="passwordChange"
|
ref="passwordChange"
|
||||||
id="passwordChange"
|
id="passwordChange"
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="error-text">
|
|
||||||
{{ form.error || '\u00A0' }}
|
|
||||||
</h3>
|
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<input id="close" type="reset" value="Close" class="button" @click=close />
|
<input id="close" type="reset" value="Close" class="button" @click=close />
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
@@ -54,28 +51,26 @@ import { useMainStore } from '@/stores/main'
|
|||||||
|
|
||||||
const confirmLoading = ref<boolean>(false)
|
const confirmLoading = ref<boolean>(false)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const passwordChange = ref()
|
const passwordChange = ref()
|
||||||
const password = ref()
|
const password = ref()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
passwordChange: '',
|
passwordChange: '',
|
||||||
password: '',
|
password: ''
|
||||||
error: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
form.passwordChange = ''
|
form.passwordChange = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.error = ''
|
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
}
|
}
|
||||||
const submit = async (ev: Event) => {
|
const submit = async (ev: Event) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
try {
|
try {
|
||||||
form.error = ''
|
|
||||||
if (form.passwordChange) {
|
if (form.passwordChange) {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
form.error = '⚠️ Current password is required'
|
store.error = '⚠️ Current password is required'
|
||||||
password.value!.focus()
|
password.value!.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
|
|||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
form.error = httpError.message || '🛑 Unknown error'
|
store.error = httpError.message || '🛑 Unknown error'
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -92,36 +87,5 @@ const submit = async (ev: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-container {
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.button-login {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--soft-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 0;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: .5rem 2rem;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: all var(--transition-time) linear;
|
|
||||||
}
|
|
||||||
.button-login:hover, .button-login:focus {
|
|
||||||
background: var(--accent-color);
|
|
||||||
box-shadow: 0 0 .3rem #000;
|
|
||||||
}
|
|
||||||
.error-text {
|
|
||||||
color: var(--red-color);
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="action-button">
|
<button class="action-button">
|
||||||
<component :is="icon" />
|
<component :is="icons[name]" />
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { icons, type IconName } from '@/assets/svg'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
name: string
|
name: IconName
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
|||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
const ws = await WSCreate()
|
const ws = await WSCreate()
|
||||||
while (upqueue.length) {
|
while (upqueue.length) {
|
||||||
const f = upqueue[0]
|
const f = upqueue[0]!
|
||||||
const start = f.cloudPos
|
const start = f.cloudPos
|
||||||
const end = Math.min(f.file.size, start + (1<<20))
|
const end = Math.min(f.file.size, start + (1<<20))
|
||||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
const control = { name: f.cloudName, size: f.file.size, start, end }
|
||||||
|
|||||||
@@ -4,48 +4,55 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<h3>Server Settings</h3>
|
<h3>Server Settings</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input
|
<label for="publicAccess">
|
||||||
id="publicServer"
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="serverSettings.public"
|
id="publicAccess"
|
||||||
@change="updateServerSettings"
|
v-model="serverSettings.public"
|
||||||
/>
|
@change="updateServerSettings"
|
||||||
<label for="publicServer">Publicly accessible without any user account.</label>
|
/>
|
||||||
|
Public access (anyone can read and write)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h3>Users</h3>
|
<template v-if="store.server.paskia">
|
||||||
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
<h3>User Management</h3>
|
||||||
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
|
||||||
{{ success }}
|
</template>
|
||||||
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
<template v-else>
|
||||||
</div>
|
<h3>Users</h3>
|
||||||
<table class="user-table">
|
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||||
<thead>
|
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||||
<tr>
|
{{ success }}
|
||||||
<th>Username</th>
|
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||||
<th>Admin</th>
|
</div>
|
||||||
<th>Actions</th>
|
<table>
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th>Username</th>
|
||||||
<tr v-for="user in users" :key="user.username">
|
<th>Admin</th>
|
||||||
<td>{{ user.username }}</td>
|
<th>Actions</th>
|
||||||
<td>
|
</tr>
|
||||||
<input
|
</thead>
|
||||||
type="checkbox"
|
<tbody>
|
||||||
:checked="user.privileged"
|
<tr v-for="user in users" :key="user.username">
|
||||||
@change="toggleAdmin(user, $event)"
|
<td>{{ user.username }}</td>
|
||||||
:disabled="user.username === store.user.username"
|
<td>
|
||||||
/>
|
<input
|
||||||
</td>
|
type="checkbox"
|
||||||
<td>
|
:checked="user.privileged"
|
||||||
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
@change="toggleAdmin(user, $event)"
|
||||||
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
:disabled="user.username === store.user.username"
|
||||||
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>
|
||||||
</tbody>
|
<button @click="renameUser(user)" class="button small" title="Rename user">✏️</button>
|
||||||
</table>
|
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
|
||||||
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
|
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<button @click="close" class="button">Close</button>
|
<button @click="close" class="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +75,6 @@ interface User {
|
|||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const error = ref('')
|
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
@@ -77,7 +83,6 @@ const serverSettings = reactive({
|
|||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ const loadUsers = async () => {
|
|||||||
users.value = data.users
|
users.value = data.users
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to load users'
|
store.error = httpError.message || 'Failed to load users'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -98,7 +103,6 @@ const addUser = async () => {
|
|||||||
const username = window.prompt('Enter username for new user:')
|
const username = window.prompt('Enter username for new user:')
|
||||||
if (!username || !username.trim()) return
|
if (!username || !username.trim()) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await createUser(username.trim(), undefined, false)
|
const result = await createUser(username.trim(), undefined, false)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
@@ -107,19 +111,18 @@ const addUser = async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to add user'
|
store.error = httpError.message || 'Failed to add user'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAdmin = async (user: User, event: Event) => {
|
const toggleAdmin = async (user: User, event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
await updateUser(user.username, { privileged: target.checked })
|
await updateUser(user.username, { privileged: target.checked })
|
||||||
user.privileged = target.checked
|
user.privileged = target.checked
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to update user'
|
store.error = httpError.message || 'Failed to update user'
|
||||||
target.checked = user.privileged // revert
|
target.checked = user.privileged // revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +133,6 @@ const renameUser = async (user: User) => {
|
|||||||
// For rename, we need to create new user and delete old, or have a rename endpoint
|
// For rename, we need to create new user and delete old, or have a rename endpoint
|
||||||
// Since no rename endpoint, perhaps delete and create
|
// Since no rename endpoint, perhaps delete and create
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await createUser(newName.trim(), undefined, user.privileged)
|
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||||||
await deleteUser(user.username)
|
await deleteUser(user.username)
|
||||||
@@ -140,14 +142,13 @@ const renameUser = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to rename user'
|
store.error = httpError.message || 'Failed to rename user'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPassword = async (user: User) => {
|
const resetPassword = async (user: User) => {
|
||||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
const result = await updateUser(user.username, { password: "" })
|
const result = await updateUser(user.username, { password: "" })
|
||||||
if (result.password) {
|
if (result.password) {
|
||||||
@@ -155,26 +156,25 @@ const resetPassword = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to reset password'
|
store.error = httpError.message || 'Failed to reset password'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteUserAction = async (username: string) => {
|
const deleteUserAction = async (username: string) => {
|
||||||
if (!confirm(`Delete user ${username}?`)) return
|
if (!confirm(`Delete user ${username}?`)) return
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
await deleteUser(username)
|
await deleteUser(username)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to delete user'
|
store.error = httpError.message || 'Failed to delete user'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||||
if (passwordMatch) {
|
if (passwordMatch) {
|
||||||
await navigator.clipboard.writeText(passwordMatch[1])
|
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||||
if (isButtonClick) {
|
if (isButtonClick) {
|
||||||
// Show "Copied!" indication on button
|
// Show "Copied!" indication on button
|
||||||
copyButtonText.value = '✅ Copied!'
|
copyButtonText.value = '✅ Copied!'
|
||||||
@@ -195,7 +195,6 @@ const copySuccess = async (isButtonClick: boolean = false) => {
|
|||||||
|
|
||||||
const updateServerSettings = async () => {
|
const updateServerSettings = async () => {
|
||||||
try {
|
try {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
success.value = ''
|
||||||
await updatePublic(serverSettings.public)
|
await updatePublic(serverSettings.public)
|
||||||
// Update store
|
// Update store
|
||||||
@@ -203,63 +202,27 @@ const updateServerSettings = async () => {
|
|||||||
success.value = 'Server settings updated'
|
success.value = 'Server settings updated'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
error.value = httpError.message || 'Failed to update settings'
|
store.error = httpError.message || 'Failed to update settings'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
serverSettings.public = store.server.public
|
serverSettings.public = store.server.public || false
|
||||||
loadUsers()
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load users when dialog opens (only in built-in auth mode)
|
||||||
|
watch(() => store.dialog, (newVal) => {
|
||||||
|
if (newVal === 'usermgmt' && !store.server.paskia) {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => store.server.public, (newVal) => {
|
watch(() => store.server.public, (newVal) => {
|
||||||
serverSettings.public = newVal
|
serverSettings.public = newVal || false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-table {
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
width: 100%;
|
</style>
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.user-table th, .user-table td {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.user-table th {
|
|
||||||
background: var(--soft-color);
|
|
||||||
}
|
|
||||||
.button.small {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
.button.danger {
|
|
||||||
background: var(--red-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.button.danger:hover {
|
|
||||||
background: #d00;
|
|
||||||
}
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.form-row label {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
.success-message {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
|
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
// Type for API error responses
|
||||||
|
interface ApiError {
|
||||||
|
error: {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasError(msg: unknown): msg is ApiError {
|
||||||
|
return typeof msg === 'object' && msg !== null && 'error' in msg
|
||||||
|
}
|
||||||
|
|
||||||
class ClientClass {
|
class ClientClass {
|
||||||
async get(url: string): Promise<any> {
|
async get(url: string): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, { method: 'GET' })
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
|
||||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
async put(url: string, data?: Record<string, any>): Promise<any> {
|
async put(url: string, data?: Record<string, any>): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
|
||||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
async delete(url: string): Promise<any> {
|
async delete(url: string): Promise<any> {
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = await res.json()
|
const msg = await apiJson(url, { method: 'DELETE' })
|
||||||
|
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
|
||||||
|
return msg
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
if (e instanceof AuthCancelledError) {
|
||||||
|
throw new SimpleError(401, 'Authentication cancelled')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { apiFetch }
|
||||||
export default Client
|
export default Client
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ export type DocProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
private _name: string = ""
|
|
||||||
public loc: string = ""
|
public loc: string = ""
|
||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
public haystack: string = ""
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
|
/** @internal Use the name getter/setter instead */
|
||||||
|
public _name: string = ""
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
|
const { name, ...rest } = props
|
||||||
|
Object.assign(this, rest)
|
||||||
|
if (name) this.name = name // Use setter for validation
|
||||||
|
}
|
||||||
get name() { return this._name }
|
get name() { return this._name }
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Client from '@/repositories/Client'
|
import Client from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
export const url_login = '/login'
|
export const url_login = '/auth/login'
|
||||||
export const url_logout = '/logout'
|
export const url_logout = '/auth/api/logout'
|
||||||
export const url_password = '/password-change'
|
export const url_password = '/auth/password-change'
|
||||||
|
|
||||||
export async function loginUser(username: string, password: string) {
|
export async function loginUser(username: string, password: string) {
|
||||||
const user = await Client.post(url_login, {
|
const user = await Client.post(url_login, {
|
||||||
@@ -25,7 +25,7 @@ export async function changePassword(username: string, passwordChange: string, p
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const url_users = '/users'
|
export const url_users = '/auth/users'
|
||||||
|
|
||||||
export async function listUsers() {
|
export async function listUsers() {
|
||||||
const data = await Client.get(url_users)
|
const data = await Client.get(url_users)
|
||||||
@@ -51,7 +51,7 @@ export async function deleteUser(username: string) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePublic(publicFlag: boolean) {
|
export async function updatePublic(isPublic: boolean) {
|
||||||
const data = await Client.put('/config/public', { public: publicFlag })
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMainStore } from "@/stores/main"
|
import { useMainStore } from "@/stores/main"
|
||||||
|
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
export const controlUrl = '/api/control'
|
||||||
@@ -8,6 +9,13 @@ export const watchUrl = '/api/watch'
|
|||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
let reconnDelay = 500
|
let reconnDelay = 500
|
||||||
let wsWatch = null as WebSocket | null
|
let wsWatch = null as WebSocket | null
|
||||||
|
// Track when we're awaiting authentication to prevent reconnection loops
|
||||||
|
let awaitingAuth = false
|
||||||
|
|
||||||
|
// Clear the local tree cache (called on logout/auth failure)
|
||||||
|
export const clearTree = () => {
|
||||||
|
tree = []
|
||||||
|
}
|
||||||
|
|
||||||
export const loadSession = () => {
|
export const loadSession = () => {
|
||||||
const s = localStorage['cista-files']
|
const s = localStorage['cista-files']
|
||||||
@@ -34,6 +42,44 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
|
|||||||
return webSocket
|
return webSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
|
||||||
|
async function handleWsAuthError(msg: any) {
|
||||||
|
const iframe = msg.error?.auth?.iframe
|
||||||
|
if (iframe) {
|
||||||
|
// Clear sensitive data immediately on auth failure
|
||||||
|
const store = useMainStore()
|
||||||
|
store.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
// Stop reconnection attempts while showing auth dialog
|
||||||
|
awaitingAuth = true
|
||||||
|
store.authInProgress = true
|
||||||
|
store.error = '' // Clear any connection message
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await showAuthIframe(iframe)
|
||||||
|
// Auth succeeded - reconnect
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
watchConnect()
|
||||||
|
} catch (e) {
|
||||||
|
awaitingAuth = false
|
||||||
|
store.authInProgress = false
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
console.log('User cancelled authentication')
|
||||||
|
// Show access denied dialog
|
||||||
|
store.dialog = 'accessdenied'
|
||||||
|
} else {
|
||||||
|
console.error('Auth iframe error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const watchConnect = () => {
|
export const watchConnect = () => {
|
||||||
if (watchTimeout !== null) {
|
if (watchTimeout !== null) {
|
||||||
clearTimeout(watchTimeout)
|
clearTimeout(watchTimeout)
|
||||||
@@ -51,9 +97,9 @@ export const watchConnect = () => {
|
|||||||
if (store.connected) return
|
if (store.connected) return
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg)
|
||||||
} else {
|
} else {
|
||||||
store.error = msg.error.message
|
store.error = msg.error.message
|
||||||
}
|
}
|
||||||
@@ -67,7 +113,6 @@ export const watchConnect = () => {
|
|||||||
store.error = ''
|
store.error = ''
|
||||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||||
else if (store.isUserLogged) store.logout()
|
else if (store.isUserLogged) store.logout()
|
||||||
if (!msg.server.public && !msg.user) store.dialog = 'login'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,21 +123,31 @@ export const watchDisconnect = () => {
|
|||||||
wsWatch = null
|
wsWatch = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset auth state and reconnect - call after successful authentication
|
||||||
|
export const resumeWatching = () => {
|
||||||
|
awaitingAuth = false
|
||||||
|
if (watchTimeout !== null) {
|
||||||
|
clearTimeout(watchTimeout)
|
||||||
|
watchTimeout = null
|
||||||
|
}
|
||||||
|
watchConnect()
|
||||||
|
}
|
||||||
|
|
||||||
let watchTimeout: any = null
|
let watchTimeout: any = null
|
||||||
|
|
||||||
const watchReconnect = (event: MessageEvent) => {
|
const watchReconnect = (event: MessageEvent) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Don't reconnect if we're awaiting authentication or auth iframe is showing
|
||||||
|
if (awaitingAuth || isAuthIframeOpen()) {
|
||||||
|
console.log('Skipping reconnect - awaiting authentication')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (store.connected) {
|
if (store.connected) {
|
||||||
console.warn("Disconnected from server", event)
|
console.warn("Disconnected from server", event)
|
||||||
store.connected = false
|
store.connected = false
|
||||||
store.error = 'Reconnecting...'
|
store.error = 'Reconnecting...'
|
||||||
}
|
}
|
||||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||||
// Don't hammer the server while on login dialog
|
|
||||||
if (store.dialog === 'login') {
|
|
||||||
watchTimeout = setTimeout(watchReconnect, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reconnDelay = Math.min(5000, reconnDelay + 500)
|
reconnDelay = Math.min(5000, reconnDelay + 500)
|
||||||
// The server closes the websocket after errors, so we need to reopen it
|
// The server closes the websocket after errors, so we need to reopen it
|
||||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||||
@@ -152,9 +207,9 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
|||||||
|
|
||||||
function handleError(msg: errorEvent) {
|
function handleError(msg: errorEvent) {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
if (msg.error.code === 401) {
|
if (msg.error.code === 401 || msg.error.code === 403) {
|
||||||
store.user.isLoggedIn = false
|
// Show paskia auth iframe (works for both password and paskia modes)
|
||||||
store.dialog = 'login'
|
handleWsAuthError(msg as any)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-17
@@ -2,23 +2,21 @@ import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { defineStore, type StateTree } from 'pinia'
|
import { defineStore, type StateTree } from 'pinia'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { logoutUser } from '@/repositories/User'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { watchConnect } from '@/repositories/WS'
|
|
||||||
import { shallowRef } from 'vue'
|
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
|
||||||
export const useMainStore = defineStore({
|
export const useMainStore = defineStore('main', {
|
||||||
id: 'main',
|
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: shallowRef<Doc[]>([]),
|
document: [] as Doc[],
|
||||||
selected: new Set<FUID>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||||
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
@@ -33,7 +31,7 @@ export const useMainStore = defineStore({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['prefs', 'cursor', 'selected'],
|
pick: ['prefs', 'cursor', 'selected'],
|
||||||
serializer: {
|
serializer: {
|
||||||
deserialize: (data: string): StateTree => {
|
deserialize: (data: string): StateTree => {
|
||||||
const ret = JSON.parse(data)
|
const ret = JSON.parse(data)
|
||||||
@@ -69,17 +67,35 @@ export const useMainStore = defineStore({
|
|||||||
this.user.privileged = privileged
|
this.user.privileged = privileged
|
||||||
this.user.isLoggedIn = true
|
this.user.isLoggedIn = true
|
||||||
this.dialog = ''
|
this.dialog = ''
|
||||||
if (!this.connected) watchConnect()
|
if (!this.connected) resumeWatching()
|
||||||
},
|
},
|
||||||
loginDialog() {
|
clearSensitiveData() {
|
||||||
this.dialog = 'login'
|
// Clear all sensitive state on logout or auth failure
|
||||||
|
localStorage.removeItem('cista-files')
|
||||||
|
this.document = []
|
||||||
|
this.selected.clear()
|
||||||
|
this.user.username = ''
|
||||||
|
this.user.privileged = false
|
||||||
|
this.user.isLoggedIn = false
|
||||||
|
this.connected = false
|
||||||
|
this.dialog = ''
|
||||||
|
this.cursor = ''
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Logout")
|
console.log("Logout")
|
||||||
await logoutUser()
|
try {
|
||||||
this.$reset()
|
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||||
localStorage.clear()
|
if (!res.ok) {
|
||||||
history.go() // Reload page
|
const data = await res.json().catch(() => ({}))
|
||||||
|
this.error = data.message || data.detail || 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Logout failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.clearSensitiveData()
|
||||||
|
resumeWatching()
|
||||||
},
|
},
|
||||||
toggleSort(name: SortOrder) {
|
toggleSort(name: SortOrder) {
|
||||||
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
||||||
@@ -129,7 +145,7 @@ export const useMainStore = defineStore({
|
|||||||
ret.recursive.push([rel, full, doc])
|
ret.recursive.push([rel, full, doc])
|
||||||
}
|
}
|
||||||
for (const key of ret.keys) {
|
for (const key of ret.keys) {
|
||||||
const base = ret.docs[key]
|
const base = ret.docs[key]!
|
||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
add(base.name, basepath, base)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMainStore } from './main'
|
||||||
|
import { clearTree } from '@/repositories/WS'
|
||||||
|
|
||||||
|
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||||
|
const isExternalAuth = computed(() => {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
return mainStore.server?.paskia === true
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearDataOnUnauth() {
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
mainStore.clearSensitiveData()
|
||||||
|
clearTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isExternalAuth, clearDataOnUnauth }
|
||||||
|
})
|
||||||
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
|
|||||||
sorted.sort(ordering[order])
|
sorted.sort(ordering[order])
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort documents while keeping files grouped by their folder.
|
||||||
|
* - name: folders sorted by folder path, items within by name
|
||||||
|
* - modified: folders sorted by newest item within results, items within by mtime
|
||||||
|
* - size: folders sorted by largest file within results, items within by size
|
||||||
|
*/
|
||||||
|
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
|
||||||
|
if (!order) return documents
|
||||||
|
|
||||||
|
const compare = ordering[order]
|
||||||
|
|
||||||
|
// Group documents by their folder location
|
||||||
|
const byFolder = new Map<string, Doc[]>()
|
||||||
|
for (const doc of documents) {
|
||||||
|
const folder = doc.loc
|
||||||
|
if (!byFolder.has(folder)) byFolder.set(folder, [])
|
||||||
|
byFolder.get(folder)!.push(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items within each folder
|
||||||
|
for (const docs of byFolder.values()) {
|
||||||
|
docs.sort(compare)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the "best" item in each folder (first after sorting = best according to criteria)
|
||||||
|
const folderBest = new Map<string, Doc>()
|
||||||
|
for (const [folder, docs] of byFolder) {
|
||||||
|
folderBest.set(folder, docs[0]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort folders: by path for name sort, by best item for modified/size
|
||||||
|
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
|
||||||
|
if (order === 'name') {
|
||||||
|
return collator.compare(a, b)
|
||||||
|
}
|
||||||
|
return compare(folderBest.get(a)!, folderBest.get(b)!)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flatten back into a single array with folder grouping preserved
|
||||||
|
const result: Doc[] = []
|
||||||
|
for (const folder of sortedFolders) {
|
||||||
|
result.push(...byFolder.get(folder)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function getFileType(name: string): string {
|
|||||||
const dotIndex = name.lastIndexOf('.')
|
const dotIndex = name.lastIndexOf('.')
|
||||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||||
return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown'
|
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prebuilt for fast & consistent sorting
|
// Prebuilt for fast & consistent sorting
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { watchEffect, ref, computed, watch } from 'vue'
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
import { needleFormat, localeIncludes, collator } from '@/utils'
|
||||||
import { sorted } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -49,9 +49,9 @@ const documents = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const locsub = loc + '/'
|
const locsub = loc + '/'
|
||||||
// Custom sort override in effect?
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
if (order) return sorted(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
// Sort by relevance - current folder, then subfolders, then others
|
||||||
docs.sort((a, b) => (
|
docs.sort((a, b) => (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -73,7 +73,10 @@ watchEffect(() => {
|
|||||||
store.query = props.query
|
store.query = props.query
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([() => props.path.join('/'), () => props.query], () => {
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
|
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
|
||||||
|
// React to path change or initial document load (0 → non-zero)
|
||||||
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* FastAPI-Vue Vite Plugin
|
||||||
|
*
|
||||||
|
* Configures Vite for FastAPI backend integration:
|
||||||
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
*
|
||||||
|
* Environment variables (with defaults):
|
||||||
|
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||||
|
*/
|
||||||
|
|
||||||
|
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
||||||
|
|
||||||
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
// Build proxy configuration for each path
|
||||||
|
const proxy = {}
|
||||||
|
for (const path of paths) {
|
||||||
|
proxy[path] = {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: false,
|
||||||
|
ws: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "fastapi-vite",
|
||||||
|
config: () => ({
|
||||||
|
server: { proxy },
|
||||||
|
build: {
|
||||||
|
outDir: "../cista/frontend-build",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-23
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
@@ -7,18 +8,11 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
|
||||||
// Development mode:
|
|
||||||
// bun run dev # Run frontend that proxies to dev_backend
|
|
||||||
// cista -l :8000 --dev # Run backend
|
|
||||||
const dev_backend = {
|
|
||||||
target: "http://localhost:8000",
|
|
||||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
|
||||||
ws: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
|
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
||||||
vue(),
|
vue(),
|
||||||
svgLoader(), // import svg files
|
svgLoader(), // import svg files
|
||||||
Components(), // auto import components
|
Components(), // auto import components
|
||||||
@@ -36,19 +30,16 @@ export default defineConfig({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": dev_backend,
|
|
||||||
"/files": dev_backend,
|
|
||||||
"/login": dev_backend,
|
|
||||||
"/logout": dev_backend,
|
|
||||||
"/password-change": dev_backend,
|
|
||||||
"/zip": dev_backend,
|
|
||||||
"/preview": dev_backend,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
outDir: "../cista/wwwroot",
|
rollupOptions: {
|
||||||
emptyOutDir: true,
|
output: {
|
||||||
}
|
manualChunks: {
|
||||||
|
// Bundle all SVG icons into a single chunk
|
||||||
|
icons: [
|
||||||
|
'/src/assets/svg/index.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-3
@@ -28,6 +28,10 @@ dependencies = [
|
|||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"docopt>=0.6.2",
|
"docopt>=0.6.2",
|
||||||
|
"fastapi-vue>=0.5.1",
|
||||||
|
"fastapi[standard]>=0.128.0",
|
||||||
|
"html5tagger>=1.3.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
"inotify>=0.2.12",
|
"inotify>=0.2.12",
|
||||||
"msgspec>=0.19.0",
|
"msgspec>=0.19.0",
|
||||||
"natsort>=8.4.0",
|
"natsort>=8.4.0",
|
||||||
@@ -37,7 +41,7 @@ dependencies = [
|
|||||||
"pillow-heif>=1.1.0",
|
"pillow-heif>=1.1.0",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"pymupdf>=1.26.3",
|
"pymupdf>=1.26.3",
|
||||||
"sanic>=25.3.0",
|
"sanic>=25.12.0",
|
||||||
"setproctitle>=1.3.6",
|
"setproctitle>=1.3.6",
|
||||||
"stream-zip>=0.0.83",
|
"stream-zip>=0.0.83",
|
||||||
"tomli_w>=1.2.0",
|
"tomli_w>=1.2.0",
|
||||||
@@ -71,8 +75,8 @@ docs = [
|
|||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["cista/wwwroot"]
|
artifacts = ["cista/frontend-build"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
|
||||||
targets.sdist.include = [
|
targets.sdist.include = [
|
||||||
"/cista",
|
"/cista",
|
||||||
]
|
]
|
||||||
@@ -82,6 +86,7 @@ hooks.vcs.template = """
|
|||||||
__version__ = {version!r}
|
__version__ = {version!r}
|
||||||
"""
|
"""
|
||||||
only-packages = true
|
only-packages = true
|
||||||
|
packages = ["cista"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = [
|
addopts = [
|
||||||
@@ -119,6 +124,7 @@ dev = [
|
|||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
"pytest>=8.4.1",
|
"pytest>=8.4.1",
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# noqa: INP001
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from sys import stderr
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data):
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
stderr.write(">>> Building Cista frontend\n")
|
|
||||||
npm = None
|
|
||||||
bun = shutil.which("bun")
|
|
||||||
if bun is None:
|
|
||||||
npm = shutil.which("npm")
|
|
||||||
if npm is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/"
|
|
||||||
)
|
|
||||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
|
||||||
os.chdir("frontend")
|
|
||||||
try:
|
|
||||||
if npm:
|
|
||||||
stderr.write("### npm install\n")
|
|
||||||
subprocess.run([npm, "install"], check=True) # noqa: S603
|
|
||||||
stderr.write("\n### npm run build\n")
|
|
||||||
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
|
||||||
else:
|
|
||||||
assert bun
|
|
||||||
stderr.write("### bun install\n")
|
|
||||||
subprocess.run([bun, "install"], check=True) # noqa: S603
|
|
||||||
stderr.write("\n### bun run build\n")
|
|
||||||
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
|
|
||||||
finally:
|
|
||||||
os.chdir("..")
|
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/devserver.py [frontend] [--backend backend]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
frontend Vite frontend endpoint (default: localhost:5173)
|
||||||
|
--backend Cista backend endpoint (default: from config, or :8000)
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||||
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
|
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
from cista.serve import parse_listen
|
||||||
|
|
||||||
|
DEFAULT_BACKEND_PORT = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend listen address and build cista dev command.
|
||||||
|
|
||||||
|
Returns (url, cmd).
|
||||||
|
"""
|
||||||
|
config.load_config()
|
||||||
|
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
|
||||||
|
url, opts = parse_listen(listen)
|
||||||
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
|
host = opts.get("host", "localhost") or "localhost"
|
||||||
|
|
||||||
|
cmd = ["cista", "--dev", "-l", listen]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
|
async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
||||||
|
reporoot = Path(__file__).parent.parent
|
||||||
|
front = reporoot / "frontend"
|
||||||
|
if not (front / "package.json").exists():
|
||||||
|
logger.warning("Frontend source not found at %s", front)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||||
|
backend_url, sanic_cmd = setup_sanic_backend(backend)
|
||||||
|
|
||||||
|
# Tell vite where to proxy API requests
|
||||||
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||||
|
|
||||||
|
async with ProcessGroup() as pg:
|
||||||
|
install_proc = await pg.spawn(*npm_install, cwd=str(front))
|
||||||
|
await asyncio.sleep(0.2) # reduce message overlap
|
||||||
|
await pg.spawn(*sanic_cmd, cwd=str(reporoot))
|
||||||
|
|
||||||
|
# Wait for both install and backend to be ready
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
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)
|
||||||
|
await pg.spawn(*vite, cwd=str(front))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run Vite and Cista (Sanic) development servers",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=HELP_EPILOG,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"frontend",
|
||||||
|
nargs="?",
|
||||||
|
metavar="host:port",
|
||||||
|
help="Vite frontend endpoint (default: localhost:5173)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend",
|
||||||
|
"-l",
|
||||||
|
metavar="host:port",
|
||||||
|
help="Cista backend endpoint (default: from config, or :8000)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
|
asyncio.run(run_devserver(args.frontend, args.backend))
|
||||||
|
|
||||||
|
|
||||||
|
HELP_EPILOG = """
|
||||||
|
scripts/devserver.py # Default ports
|
||||||
|
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||||
|
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||||
|
|
||||||
|
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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,191 @@
|
|||||||
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class _PrefixFormatter(logging.Formatter):
|
||||||
|
"""Formatter that adds prefix based on log level."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return f"┃ ⚠️ {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(_PrefixFormatter())
|
||||||
|
logger = logging.getLogger("fastapi-vue")
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
"""Check if Node.js version is >= 20.
|
||||||
|
|
||||||
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
version_str = result.stdout.strip()
|
||||||
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
|
match = re.match(r"v(\d+)", version_str)
|
||||||
|
if match:
|
||||||
|
major_version = int(match.group(1))
|
||||||
|
if major_version >= 20:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Could not determine Node.js version")
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Raises JSRuntimeError if no suitable runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["npm", "deno", "bun"]
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
# Check for JS_RUNTIME environment variable
|
||||||
|
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||||
|
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 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
|
||||||
|
for option in 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():
|
||||||
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install = {
|
||||||
|
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install",),
|
||||||
|
"bun": ("--bun", "install"),
|
||||||
|
}
|
||||||
|
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||||
|
build = {
|
||||||
|
"deno": ("run", "-A", "npm:vite", "build"),
|
||||||
|
"npm": ("run", "build"),
|
||||||
|
"bun": ("--bun", "run", "build"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns dev_cmd (without vite-specific args).
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "dev", "--"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
logger.warning(
|
||||||
|
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [tool, *dev_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_install_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct install command.
|
||||||
|
|
||||||
|
Returns install_cmd.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install_args = {
|
||||||
|
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install", "--silent"),
|
||||||
|
"bun": ("install", "--silent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def build(folder: str = "frontend") -> None:
|
||||||
|
"""Build the frontend in the specified folder.
|
||||||
|
|
||||||
|
Raises SystemExit(1) on failure.
|
||||||
|
"""
|
||||||
|
logger.info(">>> Building %s", folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_cmd, build_cmd = find_build_tool()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(e)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||||
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd)
|
||||||
|
logger.info("")
|
||||||
|
run(build_cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 5173
|
||||||
|
DEFAULT_BACKEND_PORT = 5180
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessGroup:
|
||||||
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
|
|
||||||
|
async def spawn(
|
||||||
|
self, *cmd: str, cwd: str | None = None
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a subprocess and track it."""
|
||||||
|
logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]]))
|
||||||
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._procs.append(proc)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def wait(self, proc: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Wait for a process to complete, raise SystemExit(1) on failure."""
|
||||||
|
if await proc.wait() != 0:
|
||||||
|
logger.warning("Command failed")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, *_):
|
||||||
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
|
cleanup_task = asyncio.create_task(self._cleanup())
|
||||||
|
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):
|
||||||
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for any one process to exit
|
||||||
|
await asyncio.wait(
|
||||||
|
[asyncio.create_task(p.wait()) for p in running],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Terminate remaining processes
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wait for all to finish (with overall timeout)
|
||||||
|
still_running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if still_running:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def ready(url: str, path: str = "") -> None:
|
||||||
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
|
"""
|
||||||
|
max_attempts = 50
|
||||||
|
full_url = f"{url}{path}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(full_url, timeout=1.0)
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
logger.warning("Backend didn't start in time")
|
||||||
|
raise SystemExit(1)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
||||||
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
|
Returns (url, install_cmd, dev_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
|
||||||
|
install_cmd = find_install_tool()
|
||||||
|
dev_cmd = find_dev_tool()
|
||||||
|
if host != "localhost":
|
||||||
|
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||||
|
if port != 5173:
|
||||||
|
dev_cmd.append(f"--port={port}")
|
||||||
|
|
||||||
|
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_fastapi(
|
||||||
|
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build fastapi dev command.
|
||||||
|
|
||||||
|
Returns (url, 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"]
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"fastapi",
|
||||||
|
"dev",
|
||||||
|
"--entrypoint",
|
||||||
|
module,
|
||||||
|
"--host",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
Reference in New Issue
Block a user