Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476e9fa5b0 | ||
|
|
97309d3aad | ||
|
|
670aa582df | ||
|
|
be9fa8e920 | ||
|
|
82823e393a | ||
|
|
28a5cc37d0 | ||
|
|
956da6df2e | ||
|
|
a3437f0069 | ||
|
|
fc93e27b30 | ||
|
|
7ec5609aac | ||
|
|
80b423034d | ||
|
|
eaefdd326b | ||
|
|
e1f13abc7f | ||
|
|
b294fd0695 | ||
|
|
ab3b6b672d | ||
|
|
c7466a2fe8 | ||
|
|
be263b1398 | ||
|
|
49097eef53 |
@@ -1,62 +0,0 @@
|
|||||||
name: Build and Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: registry.rose-ash.com:5000
|
|
||||||
IMAGE_CPU: celery-l1-server
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install tools
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y --no-install-recommends openssh-client
|
|
||||||
|
|
||||||
- name: Set up SSH
|
|
||||||
env:
|
|
||||||
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_KEY" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Pull latest code on server
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
run: |
|
|
||||||
ssh "root@$DEPLOY_HOST" "
|
|
||||||
cd /root/art-dag/celery
|
|
||||||
git fetch origin main
|
|
||||||
git reset --hard origin/main
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
run: |
|
|
||||||
ssh "root@$DEPLOY_HOST" "
|
|
||||||
cd /root/art-dag/celery
|
|
||||||
docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:${{ github.sha }} .
|
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:latest
|
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:${{ github.sha }}
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Deploy stack
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
run: |
|
|
||||||
ssh "root@$DEPLOY_HOST" "
|
|
||||||
cd /root/art-dag/celery
|
|
||||||
docker stack deploy -c docker-compose.yml celery
|
|
||||||
echo 'Waiting for services to update...'
|
|
||||||
sleep 10
|
|
||||||
docker stack services celery
|
|
||||||
"
|
|
||||||
157
app/__init__.py
157
app/__init__.py
@@ -4,15 +4,37 @@ Art-DAG L1 Server Application Factory.
|
|||||||
Creates and configures the FastAPI application with all routers and middleware.
|
Creates and configures the FastAPI application with all routers and middleware.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from artdag_common import create_jinja_env
|
from artdag_common import create_jinja_env
|
||||||
|
from artdag_common.middleware.auth import get_user_from_cookie
|
||||||
|
|
||||||
from .config import settings
|
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
|
||||||
|
|
||||||
|
# Derive external base URL from oauth_redirect_uri (e.g. https://celery-artdag.rose-ash.com)
|
||||||
|
_EXTERNAL_BASE = settings.oauth_redirect_uri.rsplit("/auth/callback", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _external_url(request: Request) -> str:
|
||||||
|
"""Build external URL from request path + query, using configured base domain."""
|
||||||
|
url = f"{_EXTERNAL_BASE}{request.url.path}"
|
||||||
|
if request.url.query:
|
||||||
|
url += f"?{request.url.query}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""
|
"""
|
||||||
@@ -38,6 +60,134 @@ def create_app() -> FastAPI:
|
|||||||
async def shutdown():
|
async def shutdown():
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
# Silent auth check — auto-login via prompt=none OAuth
|
||||||
|
# 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
|
||||||
|
if (
|
||||||
|
request.method != "GET"
|
||||||
|
or any(path.startswith(p) for p in _SKIP_PREFIXES)
|
||||||
|
or request.headers.get("hx-request") # skip HTMX
|
||||||
|
):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Already logged in — but verify account hasn't logged out
|
||||||
|
if get_user_from_cookie(request):
|
||||||
|
device_id = getattr(request.state, "device_id", None)
|
||||||
|
if device_id:
|
||||||
|
try:
|
||||||
|
from .dependencies import get_redis_client
|
||||||
|
r = get_redis_client()
|
||||||
|
if not r.get(f"did_auth:{device_id}"):
|
||||||
|
# Account logged out — clear our cookie
|
||||||
|
response = await call_next(request)
|
||||||
|
response.delete_cookie("artdag_session")
|
||||||
|
response.delete_cookie("pnone_at")
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Check cooldown — don't re-check within 5 minutes
|
||||||
|
pnone_at = request.cookies.get("pnone_at")
|
||||||
|
if pnone_at:
|
||||||
|
try:
|
||||||
|
pnone_ts = float(pnone_at)
|
||||||
|
if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN:
|
||||||
|
# But first check if account signalled a login via inbox delivery
|
||||||
|
device_id = getattr(request.state, "device_id", None)
|
||||||
|
if device_id:
|
||||||
|
try:
|
||||||
|
from .dependencies import get_redis_client
|
||||||
|
r = get_redis_client()
|
||||||
|
auth_ts = r.get(f"did_auth:{device_id}")
|
||||||
|
if auth_ts and float(auth_ts) > pnone_ts:
|
||||||
|
# Login happened since our last check — retry
|
||||||
|
current_url = _external_url(request)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
|
||||||
|
status_code=302,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return await call_next(request)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Redirect to silent OAuth check
|
||||||
|
current_url = _external_url(request)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
|
||||||
|
status_code=302,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device ID middleware — track browser identity across domains
|
||||||
|
# Registered AFTER silent_auth_check so it's outermost (always runs)
|
||||||
|
@app.middleware("http")
|
||||||
|
async def device_id_middleware(request: Request, call_next):
|
||||||
|
did = request.cookies.get(_DEVICE_COOKIE)
|
||||||
|
if did:
|
||||||
|
request.state.device_id = did
|
||||||
|
request.state._new_device_id = False
|
||||||
|
else:
|
||||||
|
request.state.device_id = secrets.token_urlsafe(32)
|
||||||
|
request.state._new_device_id = True
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
if getattr(request.state, "_new_device_id", False):
|
||||||
|
response.set_cookie(
|
||||||
|
key=_DEVICE_COOKIE,
|
||||||
|
value=request.state.device_id,
|
||||||
|
max_age=_DEVICE_COOKIE_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
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/")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def coop_fragments_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
if (
|
||||||
|
request.method != "GET"
|
||||||
|
or any(path.startswith(p) for p in _FRAG_SKIP)
|
||||||
|
or request.headers.get("hx-request")
|
||||||
|
or request.headers.get(fragments.FRAGMENT_HEADER)
|
||||||
|
):
|
||||||
|
request.state.nav_tree_html = ""
|
||||||
|
request.state.auth_menu_html = ""
|
||||||
|
request.state.cart_mini_html = ""
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
from artdag_common.fragments import fetch_fragments as _fetch_frags
|
||||||
|
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
auth_params = {"email": user.email} if user and user.email else {}
|
||||||
|
nav_params = {"app_name": "artdag", "path": path}
|
||||||
|
|
||||||
|
try:
|
||||||
|
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
|
||||||
|
("blog", "nav-tree", nav_params),
|
||||||
|
("account", "auth-menu", auth_params or None),
|
||||||
|
("cart", "cart-mini", None),
|
||||||
|
])
|
||||||
|
except Exception:
|
||||||
|
nav_tree_html = auth_menu_html = cart_mini_html = ""
|
||||||
|
|
||||||
|
request.state.nav_tree_html = nav_tree_html
|
||||||
|
request.state.auth_menu_html = auth_menu_html
|
||||||
|
request.state.cart_mini_html = cart_mini_html
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# Initialize Jinja2 templates
|
# Initialize Jinja2 templates
|
||||||
template_dir = Path(__file__).parent / "templates"
|
template_dir = Path(__file__).parent / "templates"
|
||||||
app.state.templates = create_jinja_env(template_dir)
|
app.state.templates = create_jinja_env(template_dir)
|
||||||
@@ -55,11 +205,14 @@ def create_app() -> FastAPI:
|
|||||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
from .routers import auth, storage, api, recipes, cache, runs, home, effects
|
from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox, fragments, oembed
|
||||||
|
|
||||||
# Home and auth routers (root level)
|
# Home and auth routers (root level)
|
||||||
app.include_router(home.router, tags=["home"])
|
app.include_router(home.router, tags=["home"])
|
||||||
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
app.include_router(inbox.router, tags=["inbox"])
|
||||||
|
app.include_router(fragments.router, tags=["fragments"])
|
||||||
|
app.include_router(oembed.router, tags=["oembed"])
|
||||||
|
|
||||||
# Feature routers
|
# Feature routers
|
||||||
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||||
|
|||||||
@@ -44,12 +44,24 @@ class Settings:
|
|||||||
default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
|
default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
|
||||||
)
|
)
|
||||||
|
|
||||||
# L2 Server
|
# OAuth SSO (replaces L2 auth)
|
||||||
l2_server: Optional[str] = field(
|
oauth_authorize_url: str = field(
|
||||||
default_factory=lambda: os.environ.get("L2_SERVER")
|
default_factory=lambda: os.environ.get("OAUTH_AUTHORIZE_URL", "https://account.rose-ash.com/auth/oauth/authorize")
|
||||||
)
|
)
|
||||||
l2_domain: Optional[str] = field(
|
oauth_token_url: str = field(
|
||||||
default_factory=lambda: os.environ.get("L2_DOMAIN")
|
default_factory=lambda: os.environ.get("OAUTH_TOKEN_URL", "https://account.rose-ash.com/auth/oauth/token")
|
||||||
|
)
|
||||||
|
oauth_client_id: str = field(
|
||||||
|
default_factory=lambda: os.environ.get("OAUTH_CLIENT_ID", "artdag")
|
||||||
|
)
|
||||||
|
oauth_redirect_uri: str = field(
|
||||||
|
default_factory=lambda: os.environ.get("OAUTH_REDIRECT_URI", "https://celery-artdag.rose-ash.com/auth/callback")
|
||||||
|
)
|
||||||
|
oauth_logout_url: str = field(
|
||||||
|
default_factory=lambda: os.environ.get("OAUTH_LOGOUT_URL", "https://account.rose-ash.com/auth/sso-logout/")
|
||||||
|
)
|
||||||
|
secret_key: str = field(
|
||||||
|
default_factory=lambda: os.environ.get("SECRET_KEY", "change-me-in-production")
|
||||||
)
|
)
|
||||||
|
|
||||||
# GPU/Streaming settings
|
# GPU/Streaming settings
|
||||||
@@ -91,7 +103,8 @@ class Settings:
|
|||||||
output(f" ipfs_gateway_url: {self.ipfs_gateway_url}")
|
output(f" ipfs_gateway_url: {self.ipfs_gateway_url}")
|
||||||
output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...")
|
output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...")
|
||||||
output(f" streaming_gpu_persist: {self.streaming_gpu_persist}")
|
output(f" streaming_gpu_persist: {self.streaming_gpu_persist}")
|
||||||
output(f" l2_server: {self.l2_server}")
|
output(f" oauth_client_id: {self.oauth_client_id}")
|
||||||
|
output(f" oauth_authorize_url: {self.oauth_authorize_url}")
|
||||||
output("=" * 60)
|
output("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,15 +64,10 @@ async def get_current_user(request: Request) -> Optional[UserContext]:
|
|||||||
# Try header first (API clients)
|
# Try header first (API clients)
|
||||||
ctx = get_user_from_header(request)
|
ctx = get_user_from_header(request)
|
||||||
if ctx:
|
if ctx:
|
||||||
# Add l2_server from settings
|
|
||||||
ctx.l2_server = settings.l2_server
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
# Fall back to cookie (browser)
|
# Fall back to cookie (browser)
|
||||||
ctx = get_user_from_cookie(request)
|
return get_user_from_cookie(request)
|
||||||
if ctx:
|
|
||||||
ctx.l2_server = settings.l2_server
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def require_auth(request: Request) -> UserContext:
|
async def require_auth(request: Request) -> UserContext:
|
||||||
@@ -90,7 +85,7 @@ async def require_auth(request: Request) -> UserContext:
|
|||||||
if "text/html" in accept:
|
if "text/html" in accept:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=302,
|
status_code=302,
|
||||||
headers={"Location": "/login"}
|
headers={"Location": "/auth/login"}
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -1,122 +1,165 @@
|
|||||||
"""
|
"""
|
||||||
Authentication routes for L1 server.
|
Authentication routes — OAuth2 authorization code flow via account.rose-ash.com.
|
||||||
|
|
||||||
L1 doesn't handle login directly - users log in at their L2 server.
|
GET /auth/login — redirect to account OAuth authorize
|
||||||
Token is passed via URL from L2 redirect, then L1 sets its own cookie.
|
GET /auth/callback — exchange code for user info, set session cookie
|
||||||
|
GET /auth/logout — clear cookie, redirect through account SSO logout
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from itsdangerous import URLSafeSerializer
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from ..dependencies import get_redis_client
|
from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie
|
||||||
from ..services.auth_service import AuthService
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
|
_signer = None
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service():
|
def _get_signer() -> URLSafeSerializer:
|
||||||
"""Get auth service instance."""
|
global _signer
|
||||||
return AuthService(get_redis_client())
|
if _signer is None:
|
||||||
|
_signer = URLSafeSerializer(settings.secret_key, salt="oauth-state")
|
||||||
|
return _signer
|
||||||
|
|
||||||
|
|
||||||
class RevokeUserRequest(BaseModel):
|
@router.get("/login")
|
||||||
"""Request to revoke all tokens for a user."""
|
async def login(request: Request):
|
||||||
username: str
|
"""Store state + next in signed cookie, redirect to account OAuth authorize."""
|
||||||
l2_server: str
|
next_url = request.query_params.get("next", "/")
|
||||||
|
prompt = request.query_params.get("prompt", "")
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
signer = _get_signer()
|
||||||
|
state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt})
|
||||||
|
|
||||||
@router.get("")
|
device_id = getattr(request.state, "device_id", "")
|
||||||
async def auth_callback(
|
authorize_url = (
|
||||||
request: Request,
|
f"{settings.oauth_authorize_url}"
|
||||||
auth_token: str = None,
|
f"?client_id={settings.oauth_client_id}"
|
||||||
auth_service: AuthService = Depends(get_auth_service),
|
f"&redirect_uri={settings.oauth_redirect_uri}"
|
||||||
):
|
f"&device_id={device_id}"
|
||||||
"""
|
f"&state={state}"
|
||||||
Receive auth token from L2 redirect and set local cookie.
|
|
||||||
|
|
||||||
This enables cross-subdomain auth on iOS Safari which blocks shared cookies.
|
|
||||||
L2 redirects here with ?auth_token=... after user logs in.
|
|
||||||
"""
|
|
||||||
if not auth_token:
|
|
||||||
return RedirectResponse(url="/", status_code=302)
|
|
||||||
|
|
||||||
# Verify the token is valid
|
|
||||||
ctx = await auth_service.verify_token_with_l2(auth_token)
|
|
||||||
if not ctx:
|
|
||||||
return RedirectResponse(url="/", status_code=302)
|
|
||||||
|
|
||||||
# Register token for this user (for revocation by username later)
|
|
||||||
auth_service.register_user_token(ctx.username, auth_token)
|
|
||||||
|
|
||||||
# Set local first-party cookie and redirect to runs
|
|
||||||
response = RedirectResponse(url="/runs", status_code=302)
|
|
||||||
response.set_cookie(
|
|
||||||
key="auth_token",
|
|
||||||
value=auth_token,
|
|
||||||
httponly=True,
|
|
||||||
max_age=60 * 60 * 24 * 30, # 30 days
|
|
||||||
samesite="lax",
|
|
||||||
secure=True
|
|
||||||
)
|
)
|
||||||
|
if prompt:
|
||||||
|
authorize_url += f"&prompt={prompt}"
|
||||||
|
|
||||||
|
response = RedirectResponse(url=authorize_url, status_code=302)
|
||||||
|
response.set_cookie(
|
||||||
|
key="oauth_state",
|
||||||
|
value=state_payload,
|
||||||
|
max_age=600, # 10 minutes
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(request: Request):
|
||||||
|
"""Validate state, exchange code via token endpoint, set session cookie."""
|
||||||
|
code = request.query_params.get("code", "")
|
||||||
|
state = request.query_params.get("state", "")
|
||||||
|
error = request.query_params.get("error", "")
|
||||||
|
account_did = request.query_params.get("account_did", "")
|
||||||
|
|
||||||
|
# Adopt account's device ID as our own (one identity across all apps)
|
||||||
|
if account_did:
|
||||||
|
request.state.device_id = account_did
|
||||||
|
request.state._new_device_id = True # device_id middleware will set cookie
|
||||||
|
|
||||||
|
# Recover state from signed cookie
|
||||||
|
state_cookie = request.cookies.get("oauth_state", "")
|
||||||
|
signer = _get_signer()
|
||||||
|
try:
|
||||||
|
payload = signer.loads(state_cookie) if state_cookie else {}
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
next_url = payload.get("next", "/")
|
||||||
|
|
||||||
|
# Handle prompt=none rejection (user not logged in on account)
|
||||||
|
if error == "login_required":
|
||||||
|
response = RedirectResponse(url=next_url, status_code=302)
|
||||||
|
response.delete_cookie("oauth_state")
|
||||||
|
# Set cooldown cookie — don't re-check for 5 minutes
|
||||||
|
response.set_cookie(
|
||||||
|
key="pnone_at",
|
||||||
|
value=str(time.time()),
|
||||||
|
max_age=300,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
# Set device cookie if adopted
|
||||||
|
if account_did:
|
||||||
|
response.set_cookie(
|
||||||
|
key="artdag_did",
|
||||||
|
value=account_did,
|
||||||
|
max_age=30 * 24 * 3600,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Normal callback — validate state + code
|
||||||
|
if not state_cookie or not code or not state:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
if payload.get("state") != state:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
# Exchange code for user info via account's token endpoint
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
settings.oauth_token_url,
|
||||||
|
json={
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings.oauth_client_id,
|
||||||
|
"redirect_uri": settings.oauth_redirect_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if "error" in data:
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
# Map OAuth response to artdag UserContext
|
||||||
|
# Note: account token endpoint returns user.email as "username"
|
||||||
|
display_name = data.get("display_name", "")
|
||||||
|
username = data.get("username", "")
|
||||||
|
email = username # OAuth response "username" is the user's email
|
||||||
|
actor_id = f"@{username}"
|
||||||
|
|
||||||
|
user = UserContext(username=username, actor_id=actor_id, email=email)
|
||||||
|
|
||||||
|
response = RedirectResponse(url=next_url, status_code=302)
|
||||||
|
set_auth_cookie(response, user)
|
||||||
|
response.delete_cookie("oauth_state")
|
||||||
|
response.delete_cookie("pnone_at")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
"""
|
"""Clear session cookie, redirect through account SSO logout."""
|
||||||
Logout - clear local cookie and redirect to home.
|
response = RedirectResponse(url=settings.oauth_logout_url, status_code=302)
|
||||||
|
clear_auth_cookie(response)
|
||||||
Note: This only logs out of L1. User should also logout from L2.
|
response.delete_cookie("oauth_state")
|
||||||
"""
|
response.delete_cookie("pnone_at")
|
||||||
response = RedirectResponse(url="/", status_code=302)
|
|
||||||
response.delete_cookie("auth_token")
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/revoke")
|
|
||||||
async def revoke_token(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
auth_service: AuthService = Depends(get_auth_service),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Revoke a token. Called by L2 when user logs out.
|
|
||||||
|
|
||||||
The token to revoke is passed in the Authorization header.
|
|
||||||
"""
|
|
||||||
if not credentials:
|
|
||||||
raise HTTPException(401, "No token provided")
|
|
||||||
|
|
||||||
token = credentials.credentials
|
|
||||||
|
|
||||||
# Verify token is valid before revoking (ensures caller has the token)
|
|
||||||
ctx = auth_service.get_user_context_from_token(token)
|
|
||||||
if not ctx:
|
|
||||||
raise HTTPException(401, "Invalid token")
|
|
||||||
|
|
||||||
# Revoke the token
|
|
||||||
newly_revoked = auth_service.revoke_token(token)
|
|
||||||
|
|
||||||
return {"revoked": True, "newly_revoked": newly_revoked}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/revoke-user")
|
|
||||||
async def revoke_user_tokens(
|
|
||||||
request: RevokeUserRequest,
|
|
||||||
auth_service: AuthService = Depends(get_auth_service),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Revoke all tokens for a user. Called by L2 when user logs out.
|
|
||||||
|
|
||||||
This handles the case where L2 issued scoped tokens that differ from L2's own token.
|
|
||||||
"""
|
|
||||||
# Revoke all tokens registered for this user
|
|
||||||
count = auth_service.revoke_all_user_tokens(request.username)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"revoked": True,
|
|
||||||
"tokens_revoked": count,
|
|
||||||
"username": request.username
|
|
||||||
}
|
|
||||||
|
|||||||
143
app/routers/fragments.py
Normal file
143
app/routers/fragments.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Art-DAG fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/{type}`` for consumption
|
||||||
|
by coop apps via the fragment client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Registry of fragment handlers: type -> async callable(request) returning HTML str
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/internal/fragments/{fragment_type}")
|
||||||
|
async def get_fragment(fragment_type: str, request: Request):
|
||||||
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
|
return Response(content="", status_code=403)
|
||||||
|
|
||||||
|
handler = _handlers.get(fragment_type)
|
||||||
|
if handler is None:
|
||||||
|
return Response(content="", media_type="text/html", status_code=200)
|
||||||
|
html = await handler(request)
|
||||||
|
return Response(content=html, media_type="text/html", status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
# --- nav-item fragment ---
|
||||||
|
|
||||||
|
async def _nav_item_handler(request: Request) -> str:
|
||||||
|
from artdag_common import render_fragment
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||||
|
return render_fragment(templates, "fragments/nav_item.html", artdag_url=artdag_url)
|
||||||
|
|
||||||
|
|
||||||
|
_handlers["nav-item"] = _nav_item_handler
|
||||||
|
|
||||||
|
|
||||||
|
# --- link-card fragment ---
|
||||||
|
|
||||||
|
async def _link_card_handler(request: Request) -> str:
|
||||||
|
from artdag_common import render_fragment
|
||||||
|
import database
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
cid = request.query_params.get("cid", "")
|
||||||
|
content_type = request.query_params.get("type", "media")
|
||||||
|
slug = request.query_params.get("slug", "")
|
||||||
|
keys_raw = request.query_params.get("keys", "")
|
||||||
|
|
||||||
|
# Batch mode: return multiple cards separated by markers
|
||||||
|
if keys_raw:
|
||||||
|
keys = [k.strip() for k in keys_raw.split(",") if k.strip()]
|
||||||
|
parts = []
|
||||||
|
for key in keys:
|
||||||
|
parts.append(f"<!-- fragment:{key} -->")
|
||||||
|
card_html = await _render_single_link_card(
|
||||||
|
templates, key, content_type,
|
||||||
|
)
|
||||||
|
parts.append(card_html)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
# Single mode: use cid or slug
|
||||||
|
lookup_cid = cid or slug
|
||||||
|
if not lookup_cid:
|
||||||
|
return ""
|
||||||
|
return await _render_single_link_card(templates, lookup_cid, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_single_link_card(templates, cid: str, content_type: str) -> str:
|
||||||
|
import database
|
||||||
|
from artdag_common import render_fragment
|
||||||
|
|
||||||
|
if not cid:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||||
|
|
||||||
|
# Try item_types first (has metadata)
|
||||||
|
item = await database.get_item_types(cid)
|
||||||
|
# get_item_types returns a list; pick best match for content_type
|
||||||
|
meta = None
|
||||||
|
if item:
|
||||||
|
for it in item:
|
||||||
|
if it.get("type") == content_type:
|
||||||
|
meta = it
|
||||||
|
break
|
||||||
|
if not meta:
|
||||||
|
meta = item[0]
|
||||||
|
|
||||||
|
# Try friendly name for display
|
||||||
|
friendly = None
|
||||||
|
if meta and meta.get("actor_id"):
|
||||||
|
friendly = await database.get_friendly_name_by_cid(meta["actor_id"], cid)
|
||||||
|
|
||||||
|
# Try run cache if type is "run"
|
||||||
|
run = None
|
||||||
|
if content_type == "run":
|
||||||
|
run = await database.get_run_cache(cid)
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
description = ""
|
||||||
|
link = ""
|
||||||
|
|
||||||
|
if friendly:
|
||||||
|
title = friendly.get("display_name") or friendly.get("base_name", cid[:12])
|
||||||
|
elif meta:
|
||||||
|
title = meta.get("filename") or meta.get("description", cid[:12])
|
||||||
|
elif run:
|
||||||
|
title = f"Run {cid[:12]}"
|
||||||
|
else:
|
||||||
|
title = cid[:16]
|
||||||
|
|
||||||
|
if meta:
|
||||||
|
description = meta.get("description", "")
|
||||||
|
|
||||||
|
if content_type == "run":
|
||||||
|
link = f"{artdag_url}/runs/{cid}"
|
||||||
|
elif content_type == "recipe":
|
||||||
|
link = f"{artdag_url}/recipes/{cid}"
|
||||||
|
elif content_type == "effect":
|
||||||
|
link = f"{artdag_url}/effects/{cid}"
|
||||||
|
else:
|
||||||
|
link = f"{artdag_url}/cache/{cid}"
|
||||||
|
|
||||||
|
return render_fragment(
|
||||||
|
templates, "fragments/link_card.html",
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
link=link,
|
||||||
|
cid=cid,
|
||||||
|
content_type=content_type,
|
||||||
|
artdag_url=artdag_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_handlers["link-card"] = _link_card_handler
|
||||||
@@ -16,6 +16,12 @@ from ..dependencies import get_templates, get_current_user
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint — always returns 200."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def get_user_stats(actor_id: str) -> dict:
|
async def get_user_stats(actor_id: str) -> dict:
|
||||||
"""Get stats for a user."""
|
"""Get stats for a user."""
|
||||||
import database
|
import database
|
||||||
@@ -227,23 +233,8 @@ async def home(request: Request):
|
|||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login_redirect(request: Request):
|
async def login_redirect(request: Request):
|
||||||
"""
|
"""Redirect to OAuth login flow."""
|
||||||
Redirect to L2 for login.
|
return RedirectResponse(url="/auth/login", status_code=302)
|
||||||
"""
|
|
||||||
from ..config import settings
|
|
||||||
|
|
||||||
if settings.l2_server:
|
|
||||||
# Redirect to L2 login with return URL
|
|
||||||
return_url = str(request.url_for("auth_callback"))
|
|
||||||
login_url = f"{settings.l2_server}/login?return_to={return_url}"
|
|
||||||
return RedirectResponse(url=login_url, status_code=302)
|
|
||||||
|
|
||||||
# No L2 configured - show error
|
|
||||||
return HTMLResponse(
|
|
||||||
"<html><body><h1>Login not configured</h1>"
|
|
||||||
"<p>No L2 server configured for authentication.</p></body></html>",
|
|
||||||
status_code=503
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Client tarball path
|
# Client tarball path
|
||||||
|
|||||||
125
app/routers/inbox.py
Normal file
125
app/routers/inbox.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""AP-style inbox endpoint for receiving signed activities from the coop.
|
||||||
|
|
||||||
|
POST /inbox — verify HTTP Signature, dispatch by activity type.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..dependencies import get_redis_client
|
||||||
|
from ..utils.http_signatures import verify_request_signature, parse_key_id
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Cache fetched public keys in Redis for 24 hours
|
||||||
|
_KEY_CACHE_TTL = 86400
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_actor_public_key(actor_url: str) -> str | None:
|
||||||
|
"""Fetch an actor's public key, with Redis caching."""
|
||||||
|
redis = get_redis_client()
|
||||||
|
cache_key = f"actor_pubkey:{actor_url}"
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cached = redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Fetch actor JSON
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
actor_url,
|
||||||
|
headers={"Accept": "application/activity+json, application/ld+json"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning("Failed to fetch actor %s: %d", actor_url, resp.status_code)
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
log.warning("Error fetching actor %s", actor_url, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
pub_key_pem = (data.get("publicKey") or {}).get("publicKeyPem")
|
||||||
|
if not pub_key_pem:
|
||||||
|
log.warning("No publicKey in actor %s", actor_url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Cache it
|
||||||
|
redis.set(cache_key, pub_key_pem, ex=_KEY_CACHE_TTL)
|
||||||
|
return pub_key_pem
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inbox")
|
||||||
|
async def inbox(request: Request):
|
||||||
|
"""Receive signed AP activities from the coop platform."""
|
||||||
|
sig_header = request.headers.get("signature", "")
|
||||||
|
if not sig_header:
|
||||||
|
return JSONResponse({"error": "missing signature"}, status_code=401)
|
||||||
|
|
||||||
|
# Read body
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# Verify HTTP Signature
|
||||||
|
actor_url = parse_key_id(sig_header)
|
||||||
|
if not actor_url:
|
||||||
|
return JSONResponse({"error": "invalid keyId"}, status_code=401)
|
||||||
|
|
||||||
|
pub_key = await _fetch_actor_public_key(actor_url)
|
||||||
|
if not pub_key:
|
||||||
|
return JSONResponse({"error": "could not fetch public key"}, status_code=401)
|
||||||
|
|
||||||
|
req_headers = dict(request.headers)
|
||||||
|
path = request.url.path
|
||||||
|
valid = verify_request_signature(
|
||||||
|
public_key_pem=pub_key,
|
||||||
|
signature_header=sig_header,
|
||||||
|
method="POST",
|
||||||
|
path=path,
|
||||||
|
headers=req_headers,
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
log.warning("Invalid signature from %s", actor_url)
|
||||||
|
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
||||||
|
|
||||||
|
# Parse and dispatch
|
||||||
|
try:
|
||||||
|
activity = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "invalid json"}, status_code=400)
|
||||||
|
|
||||||
|
activity_type = activity.get("type", "")
|
||||||
|
log.info("Inbox received: %s from %s", activity_type, actor_url)
|
||||||
|
|
||||||
|
if activity_type == "rose:DeviceAuth":
|
||||||
|
_handle_device_auth(activity)
|
||||||
|
|
||||||
|
# Always 202 — AP convention
|
||||||
|
return JSONResponse({"status": "accepted"}, status_code=202)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_device_auth(activity: dict) -> None:
|
||||||
|
"""Set or delete did_auth:{device_id} in local Redis."""
|
||||||
|
obj = activity.get("object", {})
|
||||||
|
device_id = obj.get("device_id", "")
|
||||||
|
action = obj.get("action", "")
|
||||||
|
|
||||||
|
if not device_id:
|
||||||
|
log.warning("rose:DeviceAuth missing device_id")
|
||||||
|
return
|
||||||
|
|
||||||
|
redis = get_redis_client()
|
||||||
|
if action == "login":
|
||||||
|
redis.set(f"did_auth:{device_id}", str(time.time()), ex=30 * 24 * 3600)
|
||||||
|
log.info("did_auth set for device %s...", device_id[:16])
|
||||||
|
elif action == "logout":
|
||||||
|
redis.delete(f"did_auth:{device_id}")
|
||||||
|
log.info("did_auth cleared for device %s...", device_id[:16])
|
||||||
|
else:
|
||||||
|
log.warning("rose:DeviceAuth unknown action: %s", action)
|
||||||
74
app/routers/oembed.py
Normal file
74
app/routers/oembed.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Art-DAG oEmbed endpoint.
|
||||||
|
|
||||||
|
Returns oEmbed JSON responses for Art-DAG content (media, recipes, effects, runs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oembed")
|
||||||
|
async def oembed(request: Request):
|
||||||
|
url = request.query_params.get("url", "")
|
||||||
|
if not url:
|
||||||
|
return JSONResponse({"error": "url parameter required"}, status_code=400)
|
||||||
|
|
||||||
|
# Parse URL to extract content type and CID
|
||||||
|
# URL patterns: /cache/{cid}, /recipes/{cid}, /effects/{cid}, /runs/{cid}
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
return JSONResponse({"error": "could not parse content URL"}, status_code=404)
|
||||||
|
|
||||||
|
content_type = parts[0].rstrip("s") # recipes -> recipe, runs -> run
|
||||||
|
cid = parts[1]
|
||||||
|
|
||||||
|
import database
|
||||||
|
|
||||||
|
title = cid[:16]
|
||||||
|
thumbnail_url = None
|
||||||
|
|
||||||
|
# Look up metadata
|
||||||
|
items = await database.get_item_types(cid)
|
||||||
|
if items:
|
||||||
|
meta = items[0]
|
||||||
|
title = meta.get("filename") or meta.get("description") or title
|
||||||
|
|
||||||
|
# Try friendly name
|
||||||
|
actor_id = meta.get("actor_id")
|
||||||
|
if actor_id:
|
||||||
|
friendly = await database.get_friendly_name_by_cid(actor_id, cid)
|
||||||
|
if friendly:
|
||||||
|
title = friendly.get("display_name") or friendly.get("base_name", title)
|
||||||
|
|
||||||
|
# Media items get a thumbnail
|
||||||
|
if meta.get("type") == "media":
|
||||||
|
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||||
|
thumbnail_url = f"{artdag_url}/cache/{cid}/raw"
|
||||||
|
|
||||||
|
elif content_type == "run":
|
||||||
|
run = await database.get_run_cache(cid)
|
||||||
|
if run:
|
||||||
|
title = f"Run {cid[:12]}"
|
||||||
|
|
||||||
|
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"version": "1.0",
|
||||||
|
"type": "link",
|
||||||
|
"title": title,
|
||||||
|
"provider_name": "art-dag",
|
||||||
|
"provider_url": artdag_url,
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
if thumbnail_url:
|
||||||
|
resp["thumbnail_url"] = thumbnail_url
|
||||||
|
|
||||||
|
return JSONResponse(resp)
|
||||||
@@ -1,25 +1,46 @@
|
|||||||
{% extends "_base.html" %}
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
{% block brand %}Art-DAG L1{% endblock %}
|
{% block brand %}
|
||||||
|
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
|
||||||
{% block nav_items %}
|
<span class="text-stone-400 mx-1">|</span>
|
||||||
<nav class="flex items-center space-x-6">
|
<a href="/" class="no-underline text-stone-900">Art-DAG</a>
|
||||||
<a href="/runs" class="text-gray-300 hover:text-white {% if active_tab == 'runs' %}text-white font-medium{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
|
|
||||||
<a href="/recipes" class="text-gray-300 hover:text-white {% if active_tab == 'recipes' %}text-white font-medium{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
|
|
||||||
<a href="/effects" class="text-gray-300 hover:text-white {% if active_tab == 'effects' %}text-white font-medium{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
|
|
||||||
<a href="/media" class="text-gray-300 hover:text-white {% if active_tab == 'media' %}text-white font-medium{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
|
|
||||||
<a href="/storage" class="text-gray-300 hover:text-white {% if active_tab == 'storage' %}text-white font-medium{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
|
|
||||||
<a href="/download/client" class="text-gray-300 hover:text-white" title="Download CLI client">Client</a>
|
|
||||||
</nav>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block nav_right %}
|
{% block cart_mini %}
|
||||||
{% if user %}
|
{% if request and request.state.cart_mini_html %}
|
||||||
<div class="flex items-center space-x-4">
|
{{ request.state.cart_mini_html | safe }}
|
||||||
<span class="text-gray-400">{{ user.username }}</span>
|
|
||||||
<a href="/auth/logout" class="text-gray-300 hover:text-white">Logout</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<a href="/login" class="text-gray-300 hover:text-white">Login</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block nav_tree %}
|
||||||
|
{% if request and request.state.nav_tree_html %}
|
||||||
|
{{ request.state.nav_tree_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block auth_menu %}
|
||||||
|
{% if request and request.state.auth_menu_html %}
|
||||||
|
{{ request.state.auth_menu_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block auth_menu_mobile %}
|
||||||
|
{% if request and request.state.auth_menu_html %}
|
||||||
|
{{ request.state.auth_menu_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sub_nav %}
|
||||||
|
<div class="bg-stone-200 border-b border-stone-300">
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4">
|
||||||
|
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
|
||||||
|
<a href="/runs" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'runs' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
|
||||||
|
<a href="/recipes" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'recipes' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
|
||||||
|
<a href="/effects" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'effects' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
|
||||||
|
<a href="/media" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'media' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
|
||||||
|
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
|
||||||
|
<a href="/download/client" class="whitespace-nowrap px-3 py-1.5 rounded text-stone-700 hover:bg-stone-300" title="Download CLI client">Client</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
22
app/templates/fragments/link_card.html
Normal file
22
app/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="artdag" data-hx-disable>
|
||||||
|
<div class="flex flex-row items-center gap-3 p-3">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded bg-stone-100 flex items-center justify-center text-stone-500">
|
||||||
|
{% if content_type == "recipe" %}
|
||||||
|
<i class="fas fa-scroll text-sm"></i>
|
||||||
|
{% elif content_type == "effect" %}
|
||||||
|
<i class="fas fa-magic text-sm"></i>
|
||||||
|
{% elif content_type == "run" %}
|
||||||
|
<i class="fas fa-play-circle text-sm"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-cube text-sm"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-stone-900 text-sm truncate">{{ title }}</div>
|
||||||
|
{% if description %}
|
||||||
|
<div class="text-xs text-stone-500 clamp-2">{{ description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs text-stone-400 mt-0.5">{{ content_type }} · {{ cid[:12] }}…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
7
app/templates/fragments/nav_item.html
Normal file
7
app/templates/fragments/nav_item.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="relative nav-group">
|
||||||
|
<a href="{{ artdag_url }}"
|
||||||
|
class="justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||||
|
data-hx-disable>
|
||||||
|
<i class="fas fa-project-diagram text-sm"></i> art-dag
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
84
app/utils/http_signatures.py
Normal file
84
app/utils/http_signatures.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""HTTP Signature verification for incoming AP-style inbox requests.
|
||||||
|
|
||||||
|
Implements the same RSA-SHA256 / PKCS1v15 scheme used by the coop's
|
||||||
|
shared/utils/http_signatures.py, but only the verification side.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
|
||||||
|
|
||||||
|
def verify_request_signature(
|
||||||
|
public_key_pem: str,
|
||||||
|
signature_header: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Verify an incoming HTTP Signature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_key_pem: PEM-encoded public key of the sender.
|
||||||
|
signature_header: Value of the ``Signature`` header.
|
||||||
|
method: HTTP method (GET, POST, etc.).
|
||||||
|
path: Request path (e.g. ``/inbox``).
|
||||||
|
headers: All request headers (case-insensitive keys).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the signature is valid.
|
||||||
|
"""
|
||||||
|
parts = _parse_signature_header(signature_header)
|
||||||
|
signed_headers = parts.get("headers", "date").split()
|
||||||
|
signature_b64 = parts.get("signature", "")
|
||||||
|
|
||||||
|
# Reconstruct the signed string
|
||||||
|
lc_headers = {k.lower(): v for k, v in headers.items()}
|
||||||
|
lines: list[str] = []
|
||||||
|
for h in signed_headers:
|
||||||
|
if h == "(request-target)":
|
||||||
|
lines.append(f"(request-target): {method.lower()} {path}")
|
||||||
|
else:
|
||||||
|
lines.append(f"{h}: {lc_headers.get(h, '')}")
|
||||||
|
|
||||||
|
signed_string = "\n".join(lines)
|
||||||
|
|
||||||
|
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
||||||
|
try:
|
||||||
|
public_key.verify(
|
||||||
|
base64.b64decode(signature_b64),
|
||||||
|
signed_string.encode(),
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256(),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key_id(signature_header: str) -> str:
|
||||||
|
"""Extract the keyId from a Signature header.
|
||||||
|
|
||||||
|
keyId is typically ``https://domain/users/username#main-key``.
|
||||||
|
Returns the actor URL (strips ``#main-key``).
|
||||||
|
"""
|
||||||
|
parts = _parse_signature_header(signature_header)
|
||||||
|
key_id = parts.get("keyId", "")
|
||||||
|
return re.sub(r"#.*$", "", key_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_signature_header(header: str) -> dict[str, str]:
|
||||||
|
"""Parse a Signature header into its component parts."""
|
||||||
|
parts: dict[str, str] = {}
|
||||||
|
for part in header.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
eq = part.find("=")
|
||||||
|
if eq < 0:
|
||||||
|
continue
|
||||||
|
key = part[:eq]
|
||||||
|
val = part[eq + 1:].strip('"')
|
||||||
|
parts[key] = val
|
||||||
|
return parts
|
||||||
@@ -73,10 +73,14 @@ services:
|
|||||||
# IPFS_API multiaddr - used for all IPFS operations (add, cat, pin)
|
# IPFS_API multiaddr - used for all IPFS operations (add, cat, pin)
|
||||||
- IPFS_API=/dns/ipfs/tcp/5001
|
- IPFS_API=/dns/ipfs/tcp/5001
|
||||||
- CACHE_DIR=/data/cache
|
- CACHE_DIR=/data/cache
|
||||||
|
# Coop app internal URLs for fragment composition
|
||||||
|
- INTERNAL_URL_BLOG=http://blog:8000
|
||||||
|
- INTERNAL_URL_CART=http://cart:8000
|
||||||
|
- INTERNAL_URL_ACCOUNT=http://account:8000
|
||||||
# DATABASE_URL, ADMIN_TOKEN, ARTDAG_CLUSTER_KEY,
|
# DATABASE_URL, ADMIN_TOKEN, ARTDAG_CLUSTER_KEY,
|
||||||
# L2_SERVER, L2_DOMAIN, IPFS_GATEWAY_URL from .env file
|
# L2_SERVER, L2_DOMAIN, IPFS_GATEWAY_URL from .env file
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health')"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -102,7 +106,7 @@ services:
|
|||||||
|
|
||||||
l1-worker:
|
l1-worker:
|
||||||
image: registry.rose-ash.com:5000/celery-l1-server:latest
|
image: registry.rose-ash.com:5000/celery-l1-server:latest
|
||||||
command: sh -c "find /app -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; celery -A celery_app worker --loglevel=info -E"
|
command: sh -c "find /app -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; celery -A celery_app worker --loglevel=info -E -Q celery,gpu"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ celery[redis]>=5.3.0
|
|||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
itsdangerous>=2.0
|
||||||
|
cryptography>=41.0
|
||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
|
|||||||
Reference in New Issue
Block a user