Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc6108b7a | ||
|
|
6e649f1f07 | ||
|
|
8d68e5d237 | ||
|
|
5ee7443801 |
@@ -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: "../paskia/frontend-build",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-18
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||||
import sirv from 'sirv'
|
import sirv from 'sirv'
|
||||||
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||||
const authHost = process.env.PASKIA_AUTH_HOST
|
const authHost = process.env.PASKIA_AUTH_HOST
|
||||||
@@ -12,6 +13,14 @@ export default defineConfig(({ command }) => ({
|
|||||||
appType: 'mpa',
|
appType: 'mpa',
|
||||||
publicDir: 'public',
|
publicDir: 'public',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
fastapiVue({ paths: [
|
||||||
|
"/auth/api",
|
||||||
|
"/auth/ws",
|
||||||
|
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||||
|
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||||
|
// Passphrase links: /word1.word2.word3.word4.word5
|
||||||
|
"^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||||
|
] }),
|
||||||
vue(),
|
vue(),
|
||||||
// Auth host routing: rewrite paths when accessing dedicated auth host
|
// Auth host routing: rewrite paths when accessing dedicated auth host
|
||||||
// Must run before serve-examples to handle / correctly
|
// Must run before serve-examples to handle / correctly
|
||||||
@@ -89,24 +98,6 @@ export default defineConfig(({ command }) => ({
|
|||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['..']
|
allow: ['..']
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
// Only proxy these two specific backend API paths
|
|
||||||
'/auth/api': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
},
|
|
||||||
'/auth/ws': {
|
|
||||||
target: 'http://localhost:4402',
|
|
||||||
ws: true
|
|
||||||
},
|
|
||||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
|
||||||
'^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
},
|
|
||||||
// Passphrase links: /word1.word2.word3.word4.word5
|
|
||||||
'^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ class PaskiaConfig:
|
|||||||
host: str | None = None
|
host: str | None = None
|
||||||
port: int | None = None
|
port: int | None = None
|
||||||
uds: str | None = None
|
uds: str | None = None
|
||||||
devmode: bool = False
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ __all__ = [
|
|||||||
"remove_permission_from_organization",
|
"remove_permission_from_organization",
|
||||||
"remove_permission_from_role",
|
"remove_permission_from_role",
|
||||||
"rename_permission",
|
"rename_permission",
|
||||||
|
"set_session_host",
|
||||||
"update_credential_sign_count",
|
"update_credential_sign_count",
|
||||||
"update_organization_name",
|
"update_organization_name",
|
||||||
"update_permission",
|
"update_permission",
|
||||||
|
|||||||
+77
-173
@@ -1,16 +1,31 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
|
from paskia import globals as _globals
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
|
from paskia.config import PaskiaConfig
|
||||||
|
from paskia.fastapi import app as fastapi_app
|
||||||
|
from paskia.fastapi import reset as reset_cmd
|
||||||
|
from paskia.util import startupbox
|
||||||
from paskia.util.hostutil import normalize_origin
|
from paskia.util.hostutil import normalize_origin
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_PORT = 4401
|
||||||
DEFAULT_SERVE_PORT = 4401
|
|
||||||
|
EPILOG = """\
|
||||||
|
Examples:
|
||||||
|
paskia # localhost:4401
|
||||||
|
paskia :8080 # All interfaces, port 8080
|
||||||
|
paskia unix:/tmp/paskia.sock
|
||||||
|
paskia reset [user] # Generate passkey reset link
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def is_subdomain(sub: str, domain: str) -> bool:
|
def is_subdomain(sub: str, domain: str) -> bool:
|
||||||
@@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoint(
|
|
||||||
value: str | None, default_port: int
|
|
||||||
) -> tuple[str | None, int | None, str | None, bool]:
|
|
||||||
"""Parse an endpoint using stdlib (urllib.parse, ipaddress).
|
|
||||||
|
|
||||||
Returns (host, port, uds_path). If uds_path is not None, host/port are None.
|
|
||||||
|
|
||||||
Supported forms:
|
|
||||||
- host[:port]
|
|
||||||
- :port (uses default host)
|
|
||||||
- [ipv6][:port] (bracketed for port usage)
|
|
||||||
- ipv6 (unbracketed, no port allowed -> default port)
|
|
||||||
- unix:/path/to/socket.sock
|
|
||||||
- None -> defaults (localhost:4401)
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
|
|
||||||
- Unbracketed IPv6 like ::1 implies the default port.
|
|
||||||
"""
|
|
||||||
if not value:
|
|
||||||
return DEFAULT_HOST, default_port, None, False
|
|
||||||
|
|
||||||
# Port only (numeric) -> localhost:port
|
|
||||||
if value.isdigit():
|
|
||||||
try:
|
|
||||||
port_only = int(value)
|
|
||||||
except ValueError: # pragma: no cover (isdigit guards)
|
|
||||||
raise SystemExit(f"Invalid port '{value}'")
|
|
||||||
return DEFAULT_HOST, port_only, None, False
|
|
||||||
|
|
||||||
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
|
||||||
if value.startswith(":") and value != ":":
|
|
||||||
port_part = value[1:]
|
|
||||||
if not port_part.isdigit():
|
|
||||||
raise SystemExit(f"Invalid port in '{value}'")
|
|
||||||
return None, int(port_part), None, True
|
|
||||||
|
|
||||||
# UNIX domain socket
|
|
||||||
if value.startswith("unix:"):
|
|
||||||
uds_path = value[5:] or None
|
|
||||||
if uds_path is None:
|
|
||||||
raise SystemExit("unix: path must not be empty")
|
|
||||||
return None, None, uds_path, False
|
|
||||||
|
|
||||||
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
|
||||||
if value.count(":") > 1 and not value.startswith("["):
|
|
||||||
try:
|
|
||||||
ipaddress.IPv6Address(value)
|
|
||||||
except ValueError as e: # pragma: no cover
|
|
||||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
|
||||||
return value, default_port, None, False
|
|
||||||
|
|
||||||
# Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
|
|
||||||
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
|
||||||
host = parsed.hostname
|
|
||||||
port = parsed.port
|
|
||||||
|
|
||||||
# Host may be None if empty (e.g. ':5500')
|
|
||||||
if not host:
|
|
||||||
host = DEFAULT_HOST
|
|
||||||
if port is None:
|
|
||||||
port = default_port
|
|
||||||
|
|
||||||
# Validate IP literals (optional; hostname passes through)
|
|
||||||
try:
|
|
||||||
# Strip brackets if somehow present (urlparse removes them already)
|
|
||||||
ipaddress.ip_address(host)
|
|
||||||
except ValueError:
|
|
||||||
# Not an IP address -> treat as hostname; no action
|
|
||||||
pass
|
|
||||||
|
|
||||||
return host, port, None, False
|
|
||||||
|
|
||||||
|
|
||||||
def add_common_options(p: argparse.ArgumentParser) -> None:
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
@@ -134,45 +75,44 @@ def main():
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="paskia", description="Paskia authentication server"
|
prog="paskia",
|
||||||
|
description="Paskia authentication server",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=EPILOG,
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
# serve subcommand
|
# Primary argument: either host:port or "reset" subcommand
|
||||||
serve = sub.add_parser(
|
parser.add_argument(
|
||||||
"serve", help="Run the server (production style, no auto-reload)"
|
|
||||||
)
|
|
||||||
serve.add_argument(
|
|
||||||
"hostport",
|
"hostport",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help=(
|
help=(
|
||||||
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
||||||
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
add_common_options(serve)
|
parser.add_argument(
|
||||||
|
"reset_query",
|
||||||
# reset subcommand
|
|
||||||
reset = sub.add_parser(
|
|
||||||
"reset",
|
|
||||||
help=(
|
|
||||||
"Create a credential reset link for a user. Provide part of the display name or UUID. "
|
|
||||||
"If omitted, targets the master admin (first Administration role user in an auth:admin org)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
reset.add_argument(
|
|
||||||
"query",
|
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
|
help="For 'reset' command: user UUID or substring of display name",
|
||||||
)
|
)
|
||||||
add_common_options(reset)
|
add_common_options(parser)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "serve":
|
# Detect "reset" subcommand (first positional is "reset")
|
||||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
is_reset = args.hostport == "reset"
|
||||||
|
|
||||||
|
if is_reset:
|
||||||
|
endpoints = []
|
||||||
else:
|
else:
|
||||||
host = port = uds = all_ifaces = None # type: ignore
|
# Parse endpoint using fastapi_vue.hostutil
|
||||||
|
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
|
||||||
|
|
||||||
|
# Extract host/port/uds from first endpoint for config display and site_url
|
||||||
|
ep = endpoints[0] if endpoints else {}
|
||||||
|
host = ep.get("host")
|
||||||
|
port = ep.get("port")
|
||||||
|
uds = ep.get("uds")
|
||||||
|
|
||||||
# Collect and normalize origins, handle auth_host
|
# Collect and normalize origins, handle auth_host
|
||||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||||
@@ -193,8 +133,13 @@ def main():
|
|||||||
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
||||||
|
|
||||||
# Compute site_url and site_path for reset links
|
# Compute site_url and site_path for reset links
|
||||||
# Priority: auth_host > first origin with localhost > http://localhost:port
|
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
||||||
if args.auth_host:
|
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
||||||
|
if explicit_site_url:
|
||||||
|
# Explicit site URL from devserver or deployment config
|
||||||
|
site_url = explicit_site_url.rstrip("/")
|
||||||
|
site_path = "/" if args.auth_host else "/auth/"
|
||||||
|
elif args.auth_host:
|
||||||
site_url = args.auth_host.rstrip("/")
|
site_url = args.auth_host.rstrip("/")
|
||||||
site_path = "/"
|
site_path = "/"
|
||||||
elif origins:
|
elif origins:
|
||||||
@@ -215,8 +160,6 @@ def main():
|
|||||||
site_path = "/auth/"
|
site_path = "/auth/"
|
||||||
|
|
||||||
# Build runtime configuration
|
# Build runtime configuration
|
||||||
from paskia.config import PaskiaConfig
|
|
||||||
|
|
||||||
config = PaskiaConfig(
|
config = PaskiaConfig(
|
||||||
rp_id=args.rp_id,
|
rp_id=args.rp_id,
|
||||||
rp_name=args.rp_name or None,
|
rp_name=args.rp_name or None,
|
||||||
@@ -230,8 +173,6 @@ def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Export configuration via single JSON env variable for worker processes
|
# Export configuration via single JSON env variable for worker processes
|
||||||
import json
|
|
||||||
|
|
||||||
config_json = {
|
config_json = {
|
||||||
"rp_id": config.rp_id,
|
"rp_id": config.rp_id,
|
||||||
"rp_name": config.rp_name,
|
"rp_name": config.rp_name,
|
||||||
@@ -243,8 +184,6 @@ def main():
|
|||||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||||
|
|
||||||
# Initialize globals (without bootstrap yet)
|
# Initialize globals (without bootstrap yet)
|
||||||
from paskia import globals as _globals # local import
|
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
_globals.init(
|
_globals.init(
|
||||||
rp_id=config.rp_id,
|
rp_id=config.rp_id,
|
||||||
@@ -255,80 +194,45 @@ def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Print startup configuration
|
# Print startup configuration
|
||||||
from paskia.util import startupbox
|
|
||||||
|
|
||||||
startupbox.print_startup_config(config)
|
startupbox.print_startup_config(config)
|
||||||
|
|
||||||
# Bootstrap after startup box is printed
|
# Bootstrap after startup box is printed
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
|
||||||
|
|
||||||
asyncio.run(bootstrap_if_needed())
|
asyncio.run(bootstrap_if_needed())
|
||||||
|
|
||||||
# Handle recover-admin command (no server start)
|
# Handle reset command (no server start)
|
||||||
if args.command == "reset":
|
if is_reset:
|
||||||
from paskia.fastapi import reset as reset_cmd # local import
|
exit_code = reset_cmd.run(args.reset_query)
|
||||||
|
|
||||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
if args.command == "serve":
|
# Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
|
||||||
run_kwargs: dict = {
|
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
||||||
"log_level": "info",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
run_kwargs: dict = {
|
||||||
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
|
"log_level": "info",
|
||||||
if devmode:
|
}
|
||||||
# Security: dev mode must run on localhost:4402 to prevent
|
|
||||||
# accidental public exposure of the Vite dev server
|
|
||||||
if host != "localhost" or port != 4402:
|
|
||||||
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
||||||
run_kwargs["reload"] = True
|
|
||||||
run_kwargs["reload_dirs"] = ["paskia"]
|
|
||||||
# Suppress uvicorn startup messages in dev mode
|
|
||||||
run_kwargs["log_level"] = "warning"
|
|
||||||
|
|
||||||
if uds:
|
if devmode:
|
||||||
run_kwargs["uds"] = uds
|
# Security: dev mode must run on localhost:4402 to prevent
|
||||||
else:
|
# accidental public exposure of the Vite dev server
|
||||||
if not all_ifaces:
|
if host != "localhost" or port != 4402:
|
||||||
run_kwargs["host"] = host
|
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
||||||
run_kwargs["port"] = port
|
run_kwargs["reload"] = True
|
||||||
|
run_kwargs["reload_dirs"] = ["paskia"]
|
||||||
|
# Suppress uvicorn startup messages in dev mode
|
||||||
|
run_kwargs["log_level"] = "warning"
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if len(endpoints) > 1:
|
||||||
# Dev mode with all interfaces: use simple single-server approach
|
# Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
|
||||||
if devmode:
|
async def serve_all():
|
||||||
run_kwargs["host"] = "::"
|
async with asyncio.TaskGroup() as tg:
|
||||||
run_kwargs["port"] = port
|
for ep in endpoints:
|
||||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
tg.create_task(
|
||||||
else:
|
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
||||||
# Production: run separate servers for IPv4 and IPv6
|
)
|
||||||
from uvicorn import Config, Server # noqa: E402 local import
|
|
||||||
|
|
||||||
from paskia.fastapi import (
|
asyncio.run(serve_all())
|
||||||
app as fastapi_app, # noqa: E402 local import
|
else:
|
||||||
)
|
uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
||||||
|
|
||||||
async def serve_both():
|
|
||||||
servers = []
|
|
||||||
assert port is not None
|
|
||||||
for h in ("0.0.0.0", "::"):
|
|
||||||
try:
|
|
||||||
cfg = Config(
|
|
||||||
app=fastapi_app,
|
|
||||||
host=h,
|
|
||||||
port=port,
|
|
||||||
log_level="info",
|
|
||||||
)
|
|
||||||
servers.append(Server(cfg))
|
|
||||||
except Exception as e: # pragma: no cover
|
|
||||||
logging.warning(f"Failed to configure server for {h}: {e}")
|
|
||||||
tasks = [asyncio.create_task(s.serve()) for s in servers]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
asyncio.run(serve_both())
|
|
||||||
else:
|
|
||||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ from paskia.authsession import EXPIRES, reset_expires
|
|||||||
from paskia.fastapi import authz
|
from paskia.fastapi import authz
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import (
|
from paskia.util import (
|
||||||
frontend,
|
|
||||||
hostutil,
|
hostutil,
|
||||||
passphrase,
|
passphrase,
|
||||||
permutil,
|
permutil,
|
||||||
querysafe,
|
querysafe,
|
||||||
useragent,
|
useragent,
|
||||||
|
vitedev,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -77,7 +77,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||||
return Response(*await frontend.read("/auth/admin/index.html"))
|
return Response(*await vitedev.read("/auth/admin/index.html"))
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Organizations --------------------
|
# -------------------- Organizations --------------------
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from paskia.authsession import (
|
|||||||
from paskia.fastapi import authz, session, user
|
from paskia.fastapi import authz, session, user
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ async def forward_authentication(
|
|||||||
if wants_html:
|
if wants_html:
|
||||||
# Browser request - return full-page HTML with metadata
|
# Browser request - return full-page HTML with metadata
|
||||||
data_attrs = {"mode": e.mode, **e.metadata}
|
data_attrs = {"mode": e.mode, **e.metadata}
|
||||||
html = (await frontend.read("/int/forward/index.html"))[0]
|
html = (await vitedev.read("/int/forward/index.html"))[0]
|
||||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||||
return Response(
|
return Response(
|
||||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||||
|
|||||||
+18
-14
@@ -5,11 +5,18 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from paskia.fastapi import admin, api, auth_host, ws
|
from paskia.fastapi import admin, api, auth_host, ws
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import frontend, hostutil, passphrase
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
|
|
||||||
|
# Vue Frontend static files
|
||||||
|
frontend = Frontend(
|
||||||
|
Path(__file__).parent.parent / "frontend-build",
|
||||||
|
cached=["/auth/assets/"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Path to examples/index.html when running from source tree
|
# Path to examples/index.html when running from source tree
|
||||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||||
@@ -43,10 +50,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
||||||
if frontend.is_dev_mode():
|
if frontend.devmode:
|
||||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
await frontend.load()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -59,19 +67,11 @@ app.mount("/auth/api/admin/", admin.app)
|
|||||||
app.mount("/auth/api/", api.app)
|
app.mount("/auth/api/", api.app)
|
||||||
app.mount("/auth/ws/", ws.app)
|
app.mount("/auth/ws/", ws.app)
|
||||||
|
|
||||||
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
|
||||||
if not frontend.is_dev_mode():
|
|
||||||
app.mount(
|
|
||||||
"/auth/assets/",
|
|
||||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
|
||||||
name="assets",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/restricted/")
|
@app.get("/auth/restricted/")
|
||||||
async def restricted_view():
|
async def restricted_view():
|
||||||
"""Serve the restricted/authentication UI for iframe embedding."""
|
"""Serve the restricted/authentication UI for iframe embedding."""
|
||||||
return Response(*await frontend.read("/auth/restricted/index.html"))
|
return Response(*await vitedev.read("/auth/restricted/index.html"))
|
||||||
|
|
||||||
|
|
||||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||||
@@ -86,7 +86,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||||
Access control is handled via APIs.
|
Access control is handled via APIs.
|
||||||
"""
|
"""
|
||||||
return Response(*await frontend.read("/auth/index.html"))
|
return Response(*await vitedev.read("/auth/index.html"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin", include_in_schema=False)
|
@app.get("/admin", include_in_schema=False)
|
||||||
@@ -128,4 +128,8 @@ async def token_link(token: str):
|
|||||||
if not passphrase.is_well_formed(token):
|
if not passphrase.is_well_formed(token):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return Response(*await frontend.read("/int/reset/index.html"))
|
return Response(*await vitedev.read("/int/reset/index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
# Final catch-all route for frontend files (keep at end of file)
|
||||||
|
frontend.route(app, "/")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"]
|
|||||||
|
|
||||||
def _get_dev_server() -> str | None:
|
def _get_dev_server() -> str | None:
|
||||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||||
return os.environ.get("PASKIA_DEVMODE") or None
|
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_dir() -> Path:
|
def _resolve_static_dir() -> Path:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def print_startup_config(config: "PaskiaConfig") -> None:
|
|||||||
lines.append(line(f"Auth Host: {config.auth_host}"))
|
lines.append(line(f"Auth Host: {config.auth_host}"))
|
||||||
|
|
||||||
# Show frontend URL if in dev mode
|
# Show frontend URL if in dev mode
|
||||||
devmode = os.environ.get("PASKIA_DEVMODE")
|
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
|
||||||
if devmode:
|
if devmode:
|
||||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
lines.append(line(f"Dev Frontend: {devmode}"))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Vite dev server proxy for fetching frontend files during development.
|
||||||
|
|
||||||
|
In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
|
||||||
|
In production, reads from the static build directory.
|
||||||
|
|
||||||
|
This complements fastapi_vue.Frontend which handles static file serving
|
||||||
|
but doesn't provide server-side fetching of HTML content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
__all__ = ["read"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dev_server() -> str | None:
|
||||||
|
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||||
|
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_static_dir() -> Path:
|
||||||
|
"""Resolve the static files directory."""
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||||
|
try: # pragma: no cover - trivial path resolution
|
||||||
|
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||||
|
fs_path = Path(str(pkg_dir))
|
||||||
|
if fs_path.is_dir():
|
||||||
|
return fs_path
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
pass
|
||||||
|
# Fallback for editable/development before build.
|
||||||
|
return Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
|
|
||||||
|
_static_dir: Path = _resolve_static_dir()
|
||||||
|
|
||||||
|
|
||||||
|
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||||
|
"""Read file content and return response tuple.
|
||||||
|
|
||||||
|
In dev mode, fetches from the Vite dev server.
|
||||||
|
In production, reads from the static build directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content, status_code, headers) suitable for
|
||||||
|
FastAPI Response(*args).
|
||||||
|
"""
|
||||||
|
dev_server = _get_dev_server()
|
||||||
|
if dev_server:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(f"{dev_server}{filepath}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||||
|
# Strip charset suffix if present
|
||||||
|
mime = mime.split(";")[0].strip()
|
||||||
|
return resp.content, resp.status_code, {"content-type": mime}
|
||||||
|
else:
|
||||||
|
# Production: read from static build
|
||||||
|
file_path = _static_dir / filepath.lstrip("/")
|
||||||
|
content = await asyncio.to_thread(file_path.read_bytes)
|
||||||
|
mime, _ = mimetypes.guess_type(str(file_path))
|
||||||
|
return content, 200, {"content-type": mime or "application/octet-stream"}
|
||||||
+4
-1
@@ -22,8 +22,9 @@ dependencies = [
|
|||||||
"jsondiff>=2.2.1",
|
"jsondiff>=2.2.1",
|
||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
|
"fastapi-vue>=0.3.0",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
||||||
@@ -99,3 +100,5 @@ paskia-migrate = "paskia.migrate:main"
|
|||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["paskia/frontend-build"]
|
artifacts = ["paskia/frontend-build"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||||
|
packages = ["paskia"]
|
||||||
|
only-packages = true
|
||||||
|
|||||||
Executable
+277
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
|
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/devserver.py [host:port] [--backend host:port]
|
||||||
|
|
||||||
|
The optional host:port argument sets where the Vite frontend listens.
|
||||||
|
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||||
|
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
|
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 5173
|
||||||
|
DEFAULT_BACKEND_PORT = 5180
|
||||||
|
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||||
|
|
||||||
|
EPILOG = """
|
||||||
|
scripts/devserver.py # Default ports on localhost
|
||||||
|
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||||
|
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
||||||
|
"""
|
||||||
|
|
||||||
|
BUN_BUG = """\
|
||||||
|
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||||
|
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||||
|
┃
|
||||||
|
┃ Consider using deno or npm instead for development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_frontend_tools(
|
||||||
|
vite_port: int, all_ifaces: bool
|
||||||
|
) -> tuple[list[str], list[str], str]:
|
||||||
|
"""Resolve frontend install and dev commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, dev_cmd, tool_name).
|
||||||
|
Raises SystemExit if tools are not available.
|
||||||
|
"""
|
||||||
|
if not (FRONTEND_PATH / "package.json").exists():
|
||||||
|
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
result = find_js_runtime() # noqa # type: ignore
|
||||||
|
if result is None:
|
||||||
|
if not os.environ.get("JS_RUNTIME"):
|
||||||
|
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
tool, name = result
|
||||||
|
|
||||||
|
install_args = {
|
||||||
|
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install", "--silent"),
|
||||||
|
"bun": ("install", "--silent"),
|
||||||
|
}
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "dev", "--"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
install_cmd = [tool, *install_args[name]]
|
||||||
|
dev_cmd = [
|
||||||
|
tool,
|
||||||
|
*dev_args[name],
|
||||||
|
"--clearScreen=false",
|
||||||
|
f"--port={vite_port}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if all_ifaces:
|
||||||
|
dev_cmd.append("--host")
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
stderr.write(BUN_BUG)
|
||||||
|
|
||||||
|
return install_cmd, dev_cmd, name
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_backend(host: str, port: int):
|
||||||
|
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||||
|
max_attempts = 50
|
||||||
|
url = f"http://{host}:{port}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(url, timeout=1.0)
|
||||||
|
stderr.write("✓ Backend ready!\n")
|
||||||
|
return True
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
stderr.write("┃ ⚠️ Backend didn't start in time\n")
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
|
||||||
|
"""Gracefully terminate a subprocess."""
|
||||||
|
if proc.returncode is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||||
|
except TimeoutError:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_devserver(
|
||||||
|
vite_port: int,
|
||||||
|
all_ifaces: bool,
|
||||||
|
backend_host: str,
|
||||||
|
backend_port: int,
|
||||||
|
) -> None:
|
||||||
|
"""Run the development server with install, backend, and frontend."""
|
||||||
|
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
|
||||||
|
|
||||||
|
# Tell the backend where the Vite dev server is
|
||||||
|
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
|
||||||
|
# Tell Vite where the backend is (for proxying /api requests)
|
||||||
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||||
|
|
||||||
|
backend_cmd = [
|
||||||
|
"uvicorn",
|
||||||
|
"paskia.app:app",
|
||||||
|
"--host",
|
||||||
|
backend_host,
|
||||||
|
"--port",
|
||||||
|
str(backend_port),
|
||||||
|
"--reload",
|
||||||
|
]
|
||||||
|
|
||||||
|
cwd = str(Path(__file__).parent.parent)
|
||||||
|
frontend_cwd = str(FRONTEND_PATH)
|
||||||
|
|
||||||
|
backend_proc: asyncio.subprocess.Process | None = None
|
||||||
|
install_proc: asyncio.subprocess.Process | None = None
|
||||||
|
frontend_proc: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start install (concurrent with backend)
|
||||||
|
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
|
||||||
|
install_proc = await asyncio.create_subprocess_exec(
|
||||||
|
*install_cmd, cwd=frontend_cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Start backend (concurrent with install)
|
||||||
|
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||||
|
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||||
|
|
||||||
|
# Wait for install to complete and backend to be ready
|
||||||
|
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||||
|
backend_ready_task = asyncio.create_task(
|
||||||
|
wait_for_backend(backend_host, backend_port), name="backend_ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{install_task, backend_ready_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in done:
|
||||||
|
if task.get_name() == "install":
|
||||||
|
if task.result() != 0:
|
||||||
|
stderr.write("┃ ⚠️ Install failed\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
elif task.get_name() == "backend_ready" and not task.result():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
done2, _ = await asyncio.wait(pending)
|
||||||
|
for task in done2:
|
||||||
|
if task.get_name() == "install":
|
||||||
|
if task.result() != 0:
|
||||||
|
stderr.write("┃ ⚠️ Install failed\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
elif task.get_name() == "backend_ready" and not task.result():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
install_proc = None
|
||||||
|
|
||||||
|
# Start Vite dev server
|
||||||
|
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
|
||||||
|
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
|
||||||
|
|
||||||
|
# Wait for either process to exit
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{
|
||||||
|
asyncio.create_task(backend_proc.wait(), name="backend"),
|
||||||
|
asyncio.create_task(frontend_proc.wait(), name="frontend"),
|
||||||
|
},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for t in done:
|
||||||
|
t.result()
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
stderr.write("\n✓ Shutting down...\n")
|
||||||
|
finally:
|
||||||
|
if frontend_proc is not None:
|
||||||
|
await _terminate_process(frontend_proc, "frontend")
|
||||||
|
if install_proc is not None:
|
||||||
|
await _terminate_process(install_proc, "install")
|
||||||
|
if backend_proc is not None:
|
||||||
|
await _terminate_process(backend_proc, "backend")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run Vite and FastAPI development servers",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=EPILOG,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"frontend",
|
||||||
|
nargs="?",
|
||||||
|
metavar="host:port",
|
||||||
|
help="Vite frontend endpoint (default: localhost:5173)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend",
|
||||||
|
metavar="host:port",
|
||||||
|
help="FastAPI backend endpoint (default: localhost:5180)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# parse_endpoint returns list of dicts with host/port or uds keys
|
||||||
|
# Multiple entries means bind all interfaces (IPv4 + IPv6)
|
||||||
|
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||||
|
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||||
|
|
||||||
|
# Vite doesn't support unix sockets
|
||||||
|
if "uds" in vite_endpoints[0]:
|
||||||
|
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
if "uds" in backend_endpoints[0]:
|
||||||
|
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
vite_port = vite_endpoints[0]["port"]
|
||||||
|
all_ifaces = len(vite_endpoints) > 1
|
||||||
|
backend_host = backend_endpoints[0]["host"]
|
||||||
|
backend_port = backend_endpoints[0]["port"]
|
||||||
|
|
||||||
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
|
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+27
-12
@@ -6,12 +6,17 @@ not from the installed package. It starts both the Vite frontend dev server
|
|||||||
and the FastAPI backend with auto-reload enabled.
|
and the FastAPI backend with auto-reload enabled.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/dev.py [host:port] [options...]
|
uv run scripts/devserver.py [host:port] [options...]
|
||||||
|
|
||||||
The optional host:port argument sets where the Vite frontend listens.
|
The optional host:port argument sets where the Vite frontend listens.
|
||||||
All other options are forwarded to `paskia serve`.
|
All other options are forwarded to `paskia`.
|
||||||
Backend always listens on localhost:4402.
|
Backend always listens on localhost:4402.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||||
|
FASTAPI_VUE_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
|
||||||
|
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
|
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
|
||||||
--rp-id HOST Relying Party ID (used as hostname for Caddy)
|
--rp-id HOST Relying Party ID (used as hostname for Caddy)
|
||||||
@@ -119,7 +124,11 @@ def parse_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
def run_vite(
|
def run_vite(
|
||||||
vite_url: str, vite_host: str | None, vite_port: int, auth_host: str | None = None
|
vite_url: str,
|
||||||
|
vite_host: str | None,
|
||||||
|
vite_port: int,
|
||||||
|
env: dict,
|
||||||
|
auth_host: str | None = None,
|
||||||
):
|
):
|
||||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||||
devpath = Path(__file__).parent.parent / "frontend"
|
devpath = Path(__file__).parent.parent / "frontend"
|
||||||
@@ -162,7 +171,7 @@ def run_vite(
|
|||||||
|
|
||||||
full_cmd = cmd + vite_args
|
full_cmd = cmd + vite_args
|
||||||
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
||||||
vite_env = os.environ.copy()
|
vite_env = env.copy()
|
||||||
if auth_host:
|
if auth_host:
|
||||||
vite_env["PASKIA_AUTH_HOST"] = auth_host
|
vite_env["PASKIA_AUTH_HOST"] = auth_host
|
||||||
vite_process = subprocess.Popen(
|
vite_process = subprocess.Popen(
|
||||||
@@ -391,7 +400,7 @@ def main():
|
|||||||
if all_ifaces:
|
if all_ifaces:
|
||||||
vite_host = "0.0.0.0"
|
vite_host = "0.0.0.0"
|
||||||
|
|
||||||
# Build Vite URL for PASKIA_DEVMODE (always use localhost for URL)
|
# Build Vite URL for FASTAPI_VUE_FRONTEND_URL (always use localhost for URL)
|
||||||
vite_url = f"http://localhost:{vite_port}"
|
vite_url = f"http://localhost:{vite_port}"
|
||||||
|
|
||||||
# Compute origins for Caddy (user-specified or auto-generated)
|
# Compute origins for Caddy (user-specified or auto-generated)
|
||||||
@@ -424,15 +433,21 @@ def main():
|
|||||||
if not run_caddy(caddy_origins, vite_port):
|
if not run_caddy(caddy_origins, vite_port):
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
# Start Vite dev server
|
# Set dev mode env vars for subprocesses (fastapi-vue convention)
|
||||||
run_vite(vite_url, vite_host, vite_port, args.auth_host)
|
|
||||||
|
|
||||||
# Set dev mode with Vite URL in environment for subprocess
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["PASKIA_DEVMODE"] = vite_url
|
env["FASTAPI_VUE_FRONTEND_URL"] = vite_url
|
||||||
|
env["FASTAPI_VUE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
|
||||||
|
# User-facing URL: Caddy HTTPS when running, else Vite HTTP
|
||||||
|
if args.caddy:
|
||||||
|
env["PASKIA_SITE_URL"] = caddy_origins[0] # auth-host or https://{rp-id}
|
||||||
|
else:
|
||||||
|
env["PASKIA_SITE_URL"] = vite_url
|
||||||
|
|
||||||
# Build command with origin args
|
# Start Vite dev server
|
||||||
cmd = ["paskia", "serve", f"localhost:{BACKEND_PORT}"]
|
run_vite(vite_url, vite_host, vite_port, env, args.auth_host)
|
||||||
|
|
||||||
|
# Build command with origin args (no serve subcommand, host:port is first arg)
|
||||||
|
cmd = ["paskia", f"localhost:{BACKEND_PORT}"]
|
||||||
|
|
||||||
# Pass through rp-id (always pass, has default)
|
# Pass through rp-id (always pass, has default)
|
||||||
cmd.extend(["--rp-id", args.rp_id])
|
cmd.extend(["--rp-id", args.rp_id])
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||||
|
|
||||||
|
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
"""Run a command and display it."""
|
||||||
|
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||||
|
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||||
|
subprocess.run(cmd, check=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface):
|
||||||
|
"""Build hook that compiles Vue frontend before packaging."""
|
||||||
|
|
||||||
|
def initialize(self, version, build_data):
|
||||||
|
super().initialize(version, build_data)
|
||||||
|
stderr.write(">>> Building the frontend\n")
|
||||||
|
|
||||||
|
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd, cwd="frontend")
|
||||||
|
stderr.write("\n")
|
||||||
|
run(build_cmd, cwd="frontend")
|
||||||
|
except Exception as e:
|
||||||
|
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared utilities for build and dev scripts."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str] | None:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Returns None if no runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["deno", "npm", "bun"]
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
|
||||||
|
return None
|
||||||
|
return tool, option
|
||||||
|
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Auto-detect
|
||||||
|
for option in options:
|
||||||
|
if tool := shutil.which(option):
|
||||||
|
return tool, option
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = find_js_runtime()
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Deno, npm or Bun is required for building but none was found"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool, name = result
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool():
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns (dev_cmd, tool_name) or (None, None) if not found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "dev", "--"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = find_js_runtime()
|
||||||
|
if result is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
tool, name = result
|
||||||
|
return [tool, *dev_args[name]], name
|
||||||
+3
-3
@@ -293,12 +293,12 @@ class TestSetSessionEndpoint:
|
|||||||
"""Tests for POST /auth/api/set-session"""
|
"""Tests for POST /auth/api/set-session"""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_session_without_bearer_returns_403(
|
async def test_set_session_without_bearer_returns_401(
|
||||||
self, client: httpx.AsyncClient
|
self, client: httpx.AsyncClient
|
||||||
):
|
):
|
||||||
"""Set session without bearer token should return 403."""
|
"""Set session without bearer token should return 401."""
|
||||||
response = await client.post("/auth/api/set-session")
|
response = await client.post("/auth/api/set-session")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 401
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_session_with_valid_bearer_token(
|
async def test_set_session_with_valid_bearer_token(
|
||||||
|
|||||||
Reference in New Issue
Block a user