Fix CPU HLS streaming (yuv420p) and opt-in middleware for fragments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 13m49s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 13m49s
- Add -pix_fmt yuv420p to multi_res_output.py libx264 path so browsers can decode CPU-encoded segments (was producing yuv444p / High 4:4:4). - Switch silent auth check and coop fragment middlewares from opt-out blocklists to opt-in: only run for GET requests with Accept: text/html. Prevents unnecessary nav-tree/auth-menu HTTP calls on every HLS segment, IPFS proxy, and API request. - Add opaque grant token verification to L1/L2 dependencies. - Migrate client CLI to device authorization flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
282
client/artdag.py
282
client/artdag.py
@@ -21,11 +21,11 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
# Defaults - can be overridden by env vars, config file, or CLI args
|
||||
_DEFAULT_SERVER = "http://localhost:8100"
|
||||
_DEFAULT_L2_SERVER = "http://localhost:8200"
|
||||
_DEFAULT_ACCOUNT_SERVER = "https://account.rose-ash.com"
|
||||
|
||||
# Active server URLs (set during CLI init)
|
||||
DEFAULT_SERVER = None
|
||||
DEFAULT_L2_SERVER = None
|
||||
DEFAULT_ACCOUNT_SERVER = None
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
@@ -51,9 +51,9 @@ def get_server():
|
||||
return DEFAULT_SERVER
|
||||
|
||||
|
||||
def get_l2_server():
|
||||
"""Get L2 server URL."""
|
||||
return DEFAULT_L2_SERVER
|
||||
def get_account_server():
|
||||
"""Get account server URL."""
|
||||
return DEFAULT_ACCOUNT_SERVER
|
||||
|
||||
|
||||
def load_token() -> dict:
|
||||
@@ -115,24 +115,24 @@ def _get_default_server():
|
||||
return config.get("server", _DEFAULT_SERVER)
|
||||
|
||||
|
||||
def _get_default_l2():
|
||||
"""Get default L2 server from env, config, or builtin default."""
|
||||
if os.environ.get("ARTDAG_L2"):
|
||||
return os.environ["ARTDAG_L2"]
|
||||
def _get_default_account():
|
||||
"""Get default account server from env, config, or builtin default."""
|
||||
if os.environ.get("ARTDAG_ACCOUNT"):
|
||||
return os.environ["ARTDAG_ACCOUNT"]
|
||||
config = load_config()
|
||||
return config.get("l2", _DEFAULT_L2_SERVER)
|
||||
return config.get("account", _DEFAULT_ACCOUNT_SERVER)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--server", "-s", default=None,
|
||||
help="L1 server URL (saved for future use)")
|
||||
@click.option("--l2", default=None,
|
||||
help="L2 server URL (saved for future use)")
|
||||
@click.option("--account", default=None,
|
||||
help="Account server URL (saved for future use)")
|
||||
@click.pass_context
|
||||
def cli(ctx, server, l2):
|
||||
def cli(ctx, server, account):
|
||||
"""Art DAG Client - interact with L1 rendering server."""
|
||||
ctx.ensure_object(dict)
|
||||
global DEFAULT_SERVER, DEFAULT_L2_SERVER
|
||||
global DEFAULT_SERVER, DEFAULT_ACCOUNT_SERVER
|
||||
|
||||
config = load_config()
|
||||
config_changed = False
|
||||
@@ -146,134 +146,106 @@ def cli(ctx, server, l2):
|
||||
else:
|
||||
DEFAULT_SERVER = _get_default_server()
|
||||
|
||||
if l2:
|
||||
DEFAULT_L2_SERVER = l2
|
||||
if config.get("l2") != l2:
|
||||
config["l2"] = l2
|
||||
if account:
|
||||
DEFAULT_ACCOUNT_SERVER = account
|
||||
if config.get("account") != account:
|
||||
config["account"] = account
|
||||
config_changed = True
|
||||
else:
|
||||
DEFAULT_L2_SERVER = _get_default_l2()
|
||||
DEFAULT_ACCOUNT_SERVER = _get_default_account()
|
||||
|
||||
# Save config if changed
|
||||
if config_changed:
|
||||
save_config(config)
|
||||
|
||||
ctx.obj["server"] = DEFAULT_SERVER
|
||||
ctx.obj["l2"] = DEFAULT_L2_SERVER
|
||||
ctx.obj["account"] = DEFAULT_ACCOUNT_SERVER
|
||||
|
||||
|
||||
# ============ Auth Commands ============
|
||||
|
||||
@cli.command()
|
||||
@click.argument("username")
|
||||
@click.option("--password", "-p", prompt=True, hide_input=True)
|
||||
def login(username, password):
|
||||
"""Login to get access token."""
|
||||
def login():
|
||||
"""Login via device authorization flow."""
|
||||
import webbrowser
|
||||
|
||||
account = get_account_server()
|
||||
|
||||
# Request device code
|
||||
try:
|
||||
# Server expects form data, not JSON
|
||||
resp = requests.post(
|
||||
f"{get_l2_server()}/auth/login",
|
||||
data={"username": username, "password": password}
|
||||
f"{account}/auth/device/authorize",
|
||||
json={"client_id": "artdag"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
# Check if we got a token back in a cookie
|
||||
if "auth_token" in resp.cookies:
|
||||
token = resp.cookies["auth_token"]
|
||||
# Decode token to get username and expiry
|
||||
import base64
|
||||
try:
|
||||
# JWT format: header.payload.signature
|
||||
payload = token.split(".")[1]
|
||||
# Add padding if needed
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
||||
token_data = {
|
||||
"access_token": token,
|
||||
"username": decoded.get("username", username),
|
||||
"expires_at": decoded.get("exp", "")
|
||||
}
|
||||
save_token(token_data)
|
||||
click.echo(f"Logged in as {token_data['username']}")
|
||||
if token_data.get("expires_at"):
|
||||
click.echo(f"Token expires: {token_data['expires_at']}")
|
||||
except Exception:
|
||||
# If we can't decode, just save the token
|
||||
save_token({"access_token": token, "username": username})
|
||||
click.echo(f"Logged in as {username}")
|
||||
else:
|
||||
# HTML response - check for success/error
|
||||
if "successful" in resp.text.lower():
|
||||
click.echo(f"Login successful but no token received. Try logging in via web browser.")
|
||||
elif "invalid" in resp.text.lower():
|
||||
click.echo(f"Login failed: Invalid username or password", err=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(f"Login failed: {resp.text}", err=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(f"Login failed: {resp.text}", err=True)
|
||||
sys.exit(1)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except requests.RequestException as e:
|
||||
click.echo(f"Login failed: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
device_code = data["device_code"]
|
||||
user_code = data["user_code"]
|
||||
verification_uri = data["verification_uri"]
|
||||
expires_in = data.get("expires_in", 900)
|
||||
interval = data.get("interval", 5)
|
||||
|
||||
@cli.command()
|
||||
@click.argument("username")
|
||||
@click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True)
|
||||
@click.option("--email", "-e", default=None, help="Email (optional)")
|
||||
def register(username, password, email):
|
||||
"""Register a new account."""
|
||||
click.echo("To sign in, open this URL in your browser:")
|
||||
click.echo(f" {verification_uri}")
|
||||
click.echo(f" and enter code: {user_code}")
|
||||
click.echo()
|
||||
|
||||
# Try to open browser automatically
|
||||
try:
|
||||
# Server expects form data, not JSON
|
||||
form_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password2": password,
|
||||
}
|
||||
if email:
|
||||
form_data["email"] = email
|
||||
webbrowser.open(verification_uri)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resp = requests.post(
|
||||
f"{get_l2_server()}/auth/register",
|
||||
data=form_data
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
# Check if we got a token back in a cookie
|
||||
if "auth_token" in resp.cookies:
|
||||
token = resp.cookies["auth_token"]
|
||||
# Decode token to get username and expiry
|
||||
import base64
|
||||
try:
|
||||
# JWT format: header.payload.signature
|
||||
payload = token.split(".")[1]
|
||||
# Add padding if needed
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
||||
token_data = {
|
||||
"access_token": token,
|
||||
"username": decoded.get("username", username),
|
||||
"expires_at": decoded.get("exp", "")
|
||||
}
|
||||
save_token(token_data)
|
||||
click.echo(f"Registered and logged in as {token_data['username']}")
|
||||
except Exception:
|
||||
# If we can't decode, just save the token
|
||||
save_token({"access_token": token, "username": username})
|
||||
click.echo(f"Registered and logged in as {username}")
|
||||
else:
|
||||
# HTML response - registration successful
|
||||
if "successful" in resp.text.lower():
|
||||
click.echo(f"Registered as {username}. Please login to get a token.")
|
||||
else:
|
||||
click.echo(f"Registration failed: {resp.text}", err=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(f"Registration failed: {resp.text}", err=True)
|
||||
# Poll for approval
|
||||
click.echo("Waiting for authorization", nl=False)
|
||||
deadline = time.time() + expires_in
|
||||
while time.time() < deadline:
|
||||
time.sleep(interval)
|
||||
click.echo(".", nl=False)
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{account}/auth/device/token",
|
||||
json={"device_code": device_code, "client_id": "artdag"},
|
||||
)
|
||||
data = resp.json()
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
error = data.get("error")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
elif error == "expired_token":
|
||||
click.echo()
|
||||
click.echo("Code expired. Please try again.", err=True)
|
||||
sys.exit(1)
|
||||
except requests.RequestException as e:
|
||||
click.echo(f"Registration failed: {e}", err=True)
|
||||
sys.exit(1)
|
||||
elif error == "access_denied":
|
||||
click.echo()
|
||||
click.echo("Authorization denied.", err=True)
|
||||
sys.exit(1)
|
||||
elif error:
|
||||
click.echo()
|
||||
click.echo(f"Login failed: {error}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Success
|
||||
token_data = {
|
||||
"access_token": data["access_token"],
|
||||
"username": data.get("username", ""),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
save_token(token_data)
|
||||
click.echo()
|
||||
click.echo(f"Logged in as {token_data['username'] or token_data['display_name']}")
|
||||
return
|
||||
|
||||
click.echo()
|
||||
click.echo("Timed out waiting for authorization.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@@ -291,22 +263,12 @@ def whoami():
|
||||
click.echo("Not logged in")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{get_l2_server()}/auth/me",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
user = resp.json()
|
||||
click.echo(f"Username: {user['username']}")
|
||||
click.echo(f"Created: {user['created_at']}")
|
||||
if user.get('email'):
|
||||
click.echo(f"Email: {user['email']}")
|
||||
else:
|
||||
click.echo("Token invalid or expired. Please login again.", err=True)
|
||||
clear_token()
|
||||
except requests.RequestException as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
username = token_data.get("username", "")
|
||||
display_name = token_data.get("display_name", "")
|
||||
if username:
|
||||
click.echo(f"Username: {username}")
|
||||
if display_name:
|
||||
click.echo(f"Name: {display_name}")
|
||||
|
||||
|
||||
@cli.command("config")
|
||||
@@ -332,11 +294,11 @@ def show_config(clear):
|
||||
else:
|
||||
click.echo(f" (default)")
|
||||
|
||||
click.echo(f"L2 Server: {DEFAULT_L2_SERVER}")
|
||||
if config.get("l2"):
|
||||
click.echo(f"Account Server: {DEFAULT_ACCOUNT_SERVER}")
|
||||
if config.get("account"):
|
||||
click.echo(f" (saved)")
|
||||
elif os.environ.get("ARTDAG_L2"):
|
||||
click.echo(f" (from ARTDAG_L2 env)")
|
||||
elif os.environ.get("ARTDAG_ACCOUNT"):
|
||||
click.echo(f" (from ARTDAG_ACCOUNT env)")
|
||||
else:
|
||||
click.echo(f" (default)")
|
||||
|
||||
@@ -459,7 +421,7 @@ def run(recipe, input_hash, name, wait):
|
||||
# Check auth
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve named assets
|
||||
@@ -691,7 +653,7 @@ def delete_run(run_id, force):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Get run info first
|
||||
@@ -741,7 +703,7 @@ def delete_cache(cid, force):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not force:
|
||||
@@ -927,7 +889,7 @@ def upload(filepath, name):
|
||||
# Check auth
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -980,13 +942,13 @@ def publish(run_id, output_name):
|
||||
# Check auth
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Post to L2 server with auth, including which L1 server has the run
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{get_l2_server()}/registry/record-run",
|
||||
f"{get_server()}/registry/record-run",
|
||||
json={"run_id": run_id, "output_name": output_name, "l1_server": get_server()},
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
)
|
||||
@@ -1031,7 +993,7 @@ def meta(cid, origin, origin_url, origin_note, description, tags, folder, add_co
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
headers = get_auth_header(require_token=True)
|
||||
@@ -1200,7 +1162,7 @@ def folder_list():
|
||||
"""List all folders."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1225,7 +1187,7 @@ def folder_create(path):
|
||||
"""Create a new folder."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1251,7 +1213,7 @@ def folder_delete(path):
|
||||
"""Delete a folder (must be empty)."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1287,7 +1249,7 @@ def collection_list():
|
||||
"""List all collections."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1312,7 +1274,7 @@ def collection_create(name):
|
||||
"""Create a new collection."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1338,7 +1300,7 @@ def collection_delete(name):
|
||||
"""Delete a collection."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1546,7 +1508,7 @@ def upload_recipe(filepath):
|
||||
"""Upload a recipe file (YAML or S-expression). Requires login."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Read content
|
||||
@@ -1607,7 +1569,7 @@ def upload_effect(filepath, name):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Check it's a sexp or py file
|
||||
@@ -1854,7 +1816,7 @@ def run_recipe(recipe_id, inputs, wait):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse inputs
|
||||
@@ -1922,7 +1884,7 @@ def delete_recipe(recipe_id, force):
|
||||
"""Delete a recipe. Requires login."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not force:
|
||||
@@ -1969,7 +1931,7 @@ def generate_plan(recipe_file, inputs, features, output):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Read recipe YAML
|
||||
@@ -2047,7 +2009,7 @@ def execute_plan(plan_file, wait):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Read plan JSON
|
||||
@@ -2098,7 +2060,7 @@ def run_recipe_v2(recipe_file, inputs, features, wait):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Read recipe YAML
|
||||
@@ -2213,7 +2175,7 @@ def run_status_v2(run_id):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -2267,7 +2229,7 @@ def run_stream(recipe_file, output, duration, fps, sources, audio, wait):
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
click.echo("Not logged in. Please run: artdag login", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Read recipe file
|
||||
|
||||
@@ -18,8 +18,6 @@ from artdag_common.middleware.auth import get_user_from_cookie
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Paths that should never trigger a silent auth check
|
||||
_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/", "/inbox", "/health", "/internal/", "/oembed")
|
||||
_SILENT_CHECK_COOLDOWN = 300 # 5 minutes
|
||||
_DEVICE_COOKIE = "artdag_did"
|
||||
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
||||
@@ -60,14 +58,15 @@ def create_app() -> FastAPI:
|
||||
async def shutdown():
|
||||
await close_db()
|
||||
|
||||
# Silent auth check — auto-login via prompt=none OAuth
|
||||
# Silent auth check — auto-login via prompt=none OAuth.
|
||||
# Only runs for browser page loads (Accept: text/html).
|
||||
# NOTE: registered BEFORE device_id so device_id is outermost (runs first)
|
||||
@app.middleware("http")
|
||||
async def silent_auth_check(request: Request, call_next):
|
||||
path = request.url.path
|
||||
accept = request.headers.get("accept", "")
|
||||
if (
|
||||
request.method != "GET"
|
||||
or any(path.startswith(p) for p in _SKIP_PREFIXES)
|
||||
or "text/html" not in accept
|
||||
or request.headers.get("hx-request") # skip HTMX
|
||||
):
|
||||
return await call_next(request)
|
||||
@@ -148,17 +147,14 @@ def create_app() -> FastAPI:
|
||||
return response
|
||||
|
||||
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini into
|
||||
# request.state for full-page HTML renders. Skips HTMX, API, and
|
||||
# internal paths. Failures are silent (fragments default to "").
|
||||
_FRAG_SKIP = ("/auth/", "/api/", "/internal/", "/health", "/oembed",
|
||||
"/ipfs/", "/download/", "/inbox", "/static/")
|
||||
|
||||
# request.state for full-page HTML renders. Opt-in: only fetches for
|
||||
# browser page loads (Accept: text/html, non-HTMX GET requests).
|
||||
@app.middleware("http")
|
||||
async def coop_fragments_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
accept = request.headers.get("accept", "")
|
||||
if (
|
||||
request.method != "GET"
|
||||
or any(path.startswith(p) for p in _FRAG_SKIP)
|
||||
or "text/html" not in accept
|
||||
or request.headers.get("hx-request")
|
||||
or request.headers.get(fragments.FRAGMENT_HEADER)
|
||||
):
|
||||
@@ -171,7 +167,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
user = get_user_from_cookie(request)
|
||||
auth_params = {"email": user.email or user.username} if user else {}
|
||||
nav_params = {"app_name": "artdag", "path": path}
|
||||
nav_params = {"app_name": "artdag", "path": request.url.path}
|
||||
|
||||
try:
|
||||
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
|
||||
|
||||
@@ -54,6 +54,77 @@ def get_templates(request: Request) -> Environment:
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
async def _verify_opaque_grant(token: str) -> Optional[UserContext]:
|
||||
"""Verify an opaque grant token via account server, with Redis cache."""
|
||||
import httpx
|
||||
import json
|
||||
|
||||
if not settings.internal_account_url:
|
||||
return None
|
||||
|
||||
# Check L1 Redis cache first
|
||||
cache_key = f"grant_verify:{token[:16]}"
|
||||
try:
|
||||
r = get_redis_client()
|
||||
cached = r.get(cache_key)
|
||||
if cached is not None:
|
||||
if cached == "__invalid__":
|
||||
return None
|
||||
data = json.loads(cached)
|
||||
return UserContext(
|
||||
username=data["username"],
|
||||
actor_id=data["actor_id"],
|
||||
token=token,
|
||||
email=data.get("email", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Call account server
|
||||
verify_url = f"{settings.internal_account_url.rstrip('/')}/auth/internal/verify-grant"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(verify_url, params={"token": token})
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if not data.get("valid"):
|
||||
# Cache negative result briefly
|
||||
try:
|
||||
r = get_redis_client()
|
||||
r.set(cache_key, "__invalid__", ex=60)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
username = data.get("username", "")
|
||||
display_name = data.get("display_name", "")
|
||||
actor_id = f"@{username}" if username else ""
|
||||
ctx = UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id,
|
||||
token=token,
|
||||
email=username,
|
||||
)
|
||||
|
||||
# Cache positive result for 5 minutes
|
||||
try:
|
||||
r = get_redis_client()
|
||||
cache_data = json.dumps({
|
||||
"username": username,
|
||||
"actor_id": actor_id,
|
||||
"email": username,
|
||||
"display_name": display_name,
|
||||
})
|
||||
r.set(cache_key, cache_data, ex=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> Optional[UserContext]:
|
||||
"""
|
||||
Get the current user from request (cookie or header).
|
||||
@@ -61,11 +132,19 @@ async def get_current_user(request: Request) -> Optional[UserContext]:
|
||||
This is a permissive dependency - returns None if not authenticated.
|
||||
Use require_auth for routes that require authentication.
|
||||
"""
|
||||
# Try header first (API clients)
|
||||
# Try header first (API clients — JWT tokens)
|
||||
ctx = get_user_from_header(request)
|
||||
if ctx:
|
||||
return ctx
|
||||
|
||||
# Try opaque grant token (device flow / CLI tokens)
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
ctx = await _verify_opaque_grant(token)
|
||||
if ctx:
|
||||
return ctx
|
||||
|
||||
# Fall back to cookie (browser)
|
||||
return get_user_from_cookie(request)
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ class MultiResolutionHLSOutput:
|
||||
"-bufsize", f"{quality.bitrate * 2}k",
|
||||
])
|
||||
cmd.extend([
|
||||
"-pix_fmt", "yuv420p", # Required for browser MSE compatibility
|
||||
"-g", str(int(self.fps * self.segment_duration)), # Keyframe interval = segment duration
|
||||
"-keyint_min", str(int(self.fps * self.segment_duration)),
|
||||
"-sc_threshold", "0", # Disable scene change detection for consistent segments
|
||||
|
||||
@@ -19,6 +19,34 @@ def get_templates(request: Request):
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
async def _verify_opaque_grant(token: str) -> Optional[dict]:
|
||||
"""Verify an opaque grant token via account server."""
|
||||
import httpx
|
||||
|
||||
if not settings.internal_account_url:
|
||||
return None
|
||||
|
||||
verify_url = f"{settings.internal_account_url.rstrip('/')}/auth/internal/verify-grant"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(verify_url, params={"token": token})
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if not data.get("valid"):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
username = data.get("username", "")
|
||||
return {
|
||||
"username": username,
|
||||
"actor_id": f"https://{settings.domain}/users/{username}",
|
||||
"token": token,
|
||||
"sub": username,
|
||||
}
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> Optional[dict]:
|
||||
"""
|
||||
Get current user from cookie or header.
|
||||
@@ -39,22 +67,20 @@ async def get_current_user(request: Request) -> Optional[dict]:
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Verify token
|
||||
# Verify JWT token
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
return None
|
||||
if username:
|
||||
claims = get_token_claims(token)
|
||||
if claims:
|
||||
return {
|
||||
"username": username,
|
||||
"actor_id": f"https://{settings.domain}/users/{username}",
|
||||
"token": token,
|
||||
**claims,
|
||||
}
|
||||
|
||||
# Get full claims
|
||||
claims = get_token_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"actor_id": f"https://{settings.domain}/users/{username}",
|
||||
"token": token,
|
||||
**claims,
|
||||
}
|
||||
# JWT failed — try as opaque grant token
|
||||
return await _verify_opaque_grant(token)
|
||||
|
||||
|
||||
async def require_auth(request: Request) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user