mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
fix(security): close bearer alias boundaries
This commit is contained in:
@@ -652,6 +652,50 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[str]:
|
||||
"""Enforce endpoint-picker authority for a bearer session model.
|
||||
|
||||
Direct API-key sessions intentionally have no ``ModelEndpoint`` row and
|
||||
retain their documented compatibility behavior. Registered endpoint
|
||||
sessions, including provider-auth-backed rows, must use the visible
|
||||
server-owned inventory and never trigger a provider lookup here.
|
||||
"""
|
||||
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||
requested = (getattr(sess, "model", "") or "").strip()
|
||||
if not endpoint_url or not requested:
|
||||
return None
|
||||
try:
|
||||
session_base = normalize_base(endpoint_url)
|
||||
except Exception:
|
||||
session_base = endpoint_url.rstrip("/")
|
||||
if not session_base:
|
||||
return None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
for ep in q.all():
|
||||
try:
|
||||
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
sess.model = _validate_bearer_model_selection(ep, requested)
|
||||
return sess.model
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# No registered endpoint means this is the documented direct API-key
|
||||
# compatibility path, not an endpoint-picker selection.
|
||||
return None
|
||||
|
||||
|
||||
def _session_is_research_spinoff(sess) -> bool:
|
||||
"""True if this session was created via research "Discuss" spin-off.
|
||||
|
||||
|
||||
+16
-1
@@ -63,6 +63,7 @@ from routes.model_routes import (
|
||||
from routes.chat_helpers import (
|
||||
resolve_session_auth,
|
||||
build_chat_context,
|
||||
_validate_bearer_session_model,
|
||||
save_assistant_response,
|
||||
run_post_response_tasks,
|
||||
accumulate_token_usage,
|
||||
@@ -847,6 +848,8 @@ def setup_chat_routes(
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if request_capability.is_bearer:
|
||||
_validate_bearer_session_model(sess, owner=owner)
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
@@ -869,6 +872,7 @@ def setup_chat_routes(
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Build shared context (preset, preprocess, preface, compact)
|
||||
@@ -913,6 +917,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
@@ -921,6 +926,7 @@ def setup_chat_routes(
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
candidate_request_factory = None
|
||||
selected_context_length = getattr(ctx, "context_length", 0)
|
||||
@@ -1151,7 +1157,11 @@ def setup_chat_routes(
|
||||
# its way through a plain chat request (and fail, especially with the
|
||||
# shell disabled).
|
||||
auto_escalated = False
|
||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||
_tool_intent = (
|
||||
_classify_tool_intent(message)
|
||||
if not api_token_request and isinstance(message, str)
|
||||
else None
|
||||
)
|
||||
_workspace_agent_intent = False
|
||||
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||
chat_mode = "agent"
|
||||
@@ -1388,6 +1398,8 @@ def setup_chat_routes(
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if request_capability.is_bearer:
|
||||
_validate_bearer_session_model(sess, owner=owner)
|
||||
if (
|
||||
not api_token_request
|
||||
and chat_mode == "chat"
|
||||
@@ -1501,6 +1513,7 @@ def setup_chat_routes(
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Build shared context (stream path uses enhanced_message for context preface)
|
||||
@@ -1966,6 +1979,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
_foreground_route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
@@ -1974,6 +1988,7 @@ def setup_chat_routes(
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
selected_endpoint_id=selected_endpoint_id,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
_chat_request_factory = None
|
||||
_selected_context_length = getattr(ctx, "context_length", 0)
|
||||
|
||||
+40
-1
@@ -67,9 +67,40 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
"""Run an existing route handler with request.state.current_user temporarily
|
||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
||||
Restores the original value when done. Works for sync and async handlers."""
|
||||
Temporarily hide the bearer header as well: nested legacy handlers classify
|
||||
the raw header independently of ``request.state.api_token``. Restore every
|
||||
request value when done. Works for sync and async handlers."""
|
||||
orig = getattr(request.state, "current_user", None)
|
||||
orig_api_token = getattr(request.state, "api_token", None)
|
||||
missing = object()
|
||||
scope = getattr(request, "scope", None)
|
||||
original_scope_headers = missing
|
||||
original_cached_headers = missing
|
||||
original_mapping_headers = missing
|
||||
|
||||
if isinstance(scope, dict) and "headers" in scope:
|
||||
original_scope_headers = scope["headers"]
|
||||
scope["headers"] = [
|
||||
(name, value)
|
||||
for name, value in (original_scope_headers or [])
|
||||
if not (
|
||||
(isinstance(name, bytes) and name.lower() == b"authorization")
|
||||
or (isinstance(name, str) and name.casefold() == "authorization")
|
||||
)
|
||||
]
|
||||
request_dict = getattr(request, "__dict__", {})
|
||||
if "_headers" in request_dict:
|
||||
original_cached_headers = request_dict["_headers"]
|
||||
request_dict.pop("_headers", None)
|
||||
else:
|
||||
current_headers = getattr(request, "headers", missing)
|
||||
if isinstance(current_headers, dict):
|
||||
original_mapping_headers = current_headers
|
||||
request.headers = {
|
||||
name: value
|
||||
for name, value in current_headers.items()
|
||||
if str(name).casefold() != "authorization"
|
||||
}
|
||||
request.state.current_user = owner
|
||||
request.state.api_token = False
|
||||
try:
|
||||
@@ -86,6 +117,14 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
pass
|
||||
else:
|
||||
request.state.api_token = orig_api_token
|
||||
if original_scope_headers is not missing:
|
||||
scope["headers"] = original_scope_headers
|
||||
request_dict = getattr(request, "__dict__", {})
|
||||
request_dict.pop("_headers", None)
|
||||
if original_cached_headers is not missing:
|
||||
request_dict["_headers"] = original_cached_headers
|
||||
if original_mapping_headers is not missing:
|
||||
request.headers = original_mapping_headers
|
||||
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
|
||||
@@ -4,19 +4,23 @@ import json
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
from core.database import Comparison, SessionLocal
|
||||
from core.session_manager import SessionManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
|
||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
||||
router = APIRouter(
|
||||
prefix="/api/compare",
|
||||
tags=["compare"],
|
||||
dependencies=[Depends(require_chat_scope)],
|
||||
)
|
||||
|
||||
|
||||
def _owned_endpoint_by_url(db, base_url, owner):
|
||||
@@ -64,6 +68,37 @@ class RecordVoteRequest(BaseModel):
|
||||
is_blind: bool = True
|
||||
|
||||
|
||||
def _validate_bearer_compare_models(models, owner: str) -> list[str]:
|
||||
"""Validate record-only comparison models against visible endpoint caches."""
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
selected = []
|
||||
for requested in models:
|
||||
matches = []
|
||||
for ep in endpoints:
|
||||
try:
|
||||
matches.append(_validate_bearer_model_selection(ep, requested))
|
||||
except HTTPException:
|
||||
continue
|
||||
unique = list(dict.fromkeys(matches))
|
||||
if len(unique) != 1:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Model is not permitted by a visible server endpoint: {requested}",
|
||||
)
|
||||
selected.append(unique[0])
|
||||
return selected
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_compare_routes(session_manager: SessionManager):
|
||||
"""Setup comparison routes."""
|
||||
|
||||
@@ -84,7 +119,9 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
Returns the comparison ID and the two session IDs so the client
|
||||
can fire two independent SSE streams to /api/chat_stream.
|
||||
"""
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
bearer = is_bearer_principal(request)
|
||||
comp_id = str(uuid.uuid4())
|
||||
sid_a = str(uuid.uuid4())
|
||||
sid_b = str(uuid.uuid4())
|
||||
@@ -160,6 +197,13 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
_reject_raw_endpoint_url_for_non_admin(
|
||||
request, user, str(ep.id) if ep is not None else None, endpoint
|
||||
)
|
||||
selected_model = model
|
||||
if bearer:
|
||||
if ep is None:
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
selected_model = _validate_bearer_model_selection(ep, model)
|
||||
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
||||
# caller-supplied string. When the URL matches a registered
|
||||
# endpoint visible to the caller, use that row's own normalized
|
||||
@@ -176,7 +220,7 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||
# never inherit another user's key/headers.
|
||||
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||
resolved.append((sid, model, session_endpoint_url, headers))
|
||||
resolved.append((sid, selected_model, session_endpoint_url, headers))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -203,8 +247,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
comp = Comparison(
|
||||
id=comp_id,
|
||||
prompt=prompt,
|
||||
model_a=model_a,
|
||||
model_b=model_b,
|
||||
model_a=resolved[0][1],
|
||||
model_b=resolved[1][1],
|
||||
# Record the URL the session actually dials. For URL callers this
|
||||
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
||||
# fall back to the resolved endpoint URL so the column stays
|
||||
@@ -241,7 +285,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
winner: str = Form(...), # "left", "right", or "tie"
|
||||
):
|
||||
"""Record the user's vote and reveal model names if blind."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||
@@ -283,15 +328,20 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.post("/record")
|
||||
def record_comparison(request: Request, body: RecordVoteRequest):
|
||||
"""Lightweight endpoint to record a comparison vote from the frontend."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
comp_id = str(uuid.uuid4())
|
||||
|
||||
model_a = body.models[0] if len(body.models) > 0 else ""
|
||||
model_b = body.models[1] if len(body.models) > 1 else ""
|
||||
models = list(body.models or [])
|
||||
if is_bearer_principal(request):
|
||||
models = _validate_bearer_compare_models(models, user)
|
||||
|
||||
model_a = models[0] if len(models) > 0 else ""
|
||||
model_b = models[1] if len(models) > 1 else ""
|
||||
|
||||
# For N>2 models, store the full list as JSON in blind_mapping
|
||||
if len(body.models) > 2:
|
||||
blind_mapping = json.dumps({"models": body.models})
|
||||
if len(models) > 2:
|
||||
blind_mapping = json.dumps({"models": models})
|
||||
else:
|
||||
blind_mapping = None
|
||||
|
||||
@@ -320,7 +370,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.get("/history")
|
||||
def list_comparisons(request: Request):
|
||||
"""List past comparisons."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(Comparison)
|
||||
@@ -346,7 +397,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.delete("/{comp_id}")
|
||||
def delete_comparison(request: Request, comp_id: str):
|
||||
"""Delete a comparison and its ephemeral sessions."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||
|
||||
@@ -10,11 +10,19 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
get_current_user,
|
||||
is_bearer_principal,
|
||||
owner_filter,
|
||||
require_chat_scope,
|
||||
require_non_bearer_request,
|
||||
require_privilege,
|
||||
)
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -33,6 +41,13 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _gallery_owner(request: Request) -> Optional[str]:
|
||||
"""Use the token owner for bearer calls and preserve the legacy seam otherwise."""
|
||||
if is_bearer_principal(request):
|
||||
return effective_user(request)
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
|
||||
if not image_b64:
|
||||
raise HTTPException(400, "Missing image")
|
||||
@@ -346,7 +361,10 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
||||
|
||||
|
||||
def setup_gallery_routes() -> APIRouter:
|
||||
router = APIRouter(tags=["gallery"])
|
||||
router = APIRouter(
|
||||
tags=["gallery"],
|
||||
dependencies=[Depends(require_chat_scope)],
|
||||
)
|
||||
|
||||
# ---- POST /api/gallery/upload ----
|
||||
@router.post("/api/gallery/upload")
|
||||
@@ -360,7 +378,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not file or not hasattr(file, 'filename'):
|
||||
raise HTTPException(400, "No file provided")
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
album_id = form.get("album_id") or None
|
||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload")
|
||||
|
||||
@@ -434,7 +452,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/{image_id}/replace")
|
||||
async def gallery_replace(request: Request, image_id: str):
|
||||
"""Replace an existing gallery image file with a new one."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -479,7 +497,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
"""Rename a gallery photo. Stores the new name in the `prompt`
|
||||
column (which serves as the user-facing label for uploaded
|
||||
photos that have no AI prompt)."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
new_name = (data.get("name") or "").strip()
|
||||
if not new_name:
|
||||
@@ -516,7 +534,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if angle not in (90, -90, 180, 270):
|
||||
raise HTTPException(400, "Angle must be 90, -90, 180, or 270")
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -557,7 +575,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
db.close()
|
||||
|
||||
# ---- POST /api/gallery/ai-upscale ----
|
||||
@router.post("/api/gallery/ai-upscale")
|
||||
@router.post(
|
||||
"/api/gallery/ai-upscale",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def gallery_ai_upscale(request: Request):
|
||||
"""AI upscale using img2img with the diffusion server."""
|
||||
import base64, httpx
|
||||
@@ -601,7 +622,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"error": "Upscale request failed"}
|
||||
|
||||
# ---- POST /api/gallery/style-transfer ----
|
||||
@router.post("/api/gallery/style-transfer")
|
||||
@router.post(
|
||||
"/api/gallery/style-transfer",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def gallery_style_transfer(request: Request):
|
||||
"""Style transfer using img2img with the diffusion server."""
|
||||
import base64, httpx
|
||||
@@ -651,7 +675,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.get("/api/gallery/tags")
|
||||
async def gallery_tags(request: Request) -> Dict[str, Any]:
|
||||
"""Return distinct tags across all active gallery images."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage.tags).filter(
|
||||
@@ -683,7 +707,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(24, ge=1, le=100),
|
||||
) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Distinct tags for filter UI
|
||||
@@ -811,7 +835,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.get("/api/gallery/albums")
|
||||
async def list_albums(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryAlbum)
|
||||
@@ -850,7 +874,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums")
|
||||
async def create_album(request: Request):
|
||||
import uuid
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
@@ -870,7 +894,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.get("/api/gallery/stats")
|
||||
async def gallery_stats(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
@@ -894,13 +918,16 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/api/gallery/ai-tag-batch")
|
||||
@router.post(
|
||||
"/api/gallery/ai-tag-batch",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def ai_tag_batch(
|
||||
request: Request,
|
||||
album_id: Optional[str] = Query(None),
|
||||
limit: int = Query(200),
|
||||
):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(
|
||||
@@ -919,7 +946,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- GET /api/gallery/{image_id} ----
|
||||
@router.get("/api/gallery/{image_id}")
|
||||
async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = (
|
||||
@@ -940,7 +967,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- PATCH /api/gallery/{image_id} ----
|
||||
@router.patch("/api/gallery/{image_id}")
|
||||
async def patch_gallery_image(request: Request, image_id: str, req: GalleryPatch) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -992,7 +1019,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# of a flood of individual downloads).
|
||||
@router.post("/api/gallery/download-zip")
|
||||
async def gallery_download_zip(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
try:
|
||||
@@ -1047,7 +1074,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# AI-suggested values you never added.
|
||||
@router.post("/api/gallery/clear-user-tags")
|
||||
async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1072,7 +1099,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# "woman" have leaked into the gallery and you want them gone.
|
||||
@router.post("/api/gallery/clear-ai-tags")
|
||||
async def clear_gallery_ai_tags(request: Request, image_id: Optional[str] = Query(None)) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1099,7 +1126,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# Returns how many rows were touched + how many tags removed.
|
||||
@router.post("/api/gallery/dedupe-tags")
|
||||
async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1135,7 +1162,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- DELETE /api/gallery/{image_id} ----
|
||||
@router.delete("/api/gallery/{image_id}")
|
||||
async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -1254,7 +1281,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
db.close()
|
||||
|
||||
# ---- POST /api/image/inpaint — proxy to diffusion server OR OpenAI ----
|
||||
@router.post("/api/image/inpaint")
|
||||
@router.post(
|
||||
"/api/image/inpaint",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def inpaint_proxy(request: Request):
|
||||
"""Forward inpaint request. If the selected endpoint is OpenAI, re-shape
|
||||
the request for /v1/images/edits (multipart, inverted mask). Otherwise
|
||||
@@ -1512,7 +1542,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# scratch using the prompt", ignoring the source. Real img2img sends
|
||||
# the image alongside a `strength` (denoising strength) and the model
|
||||
# mixes that fraction of new noise into the existing pixels.
|
||||
@router.post("/api/image/harmonize")
|
||||
@router.post(
|
||||
"/api/image/harmonize",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def harmonize_image(request: Request):
|
||||
"""Harmonize = img2img. The model preserves (1 - strength) of the
|
||||
original and regenerates `strength` fraction. With strength ~0.4
|
||||
@@ -1712,7 +1745,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
|
||||
|
||||
# ---- POST /api/image/sharpen ----
|
||||
@router.post("/api/image/sharpen")
|
||||
@router.post(
|
||||
"/api/image/sharpen",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def sharpen_image(request: Request):
|
||||
"""Apply unsharp-mask sharpening to an image."""
|
||||
require_privilege(request, "can_generate_images")
|
||||
@@ -1737,7 +1773,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at
|
||||
# outscale=1 + denoise_strength. Falls back to a "package missing"
|
||||
# error so the client can prompt the user to install via Cookbook.
|
||||
@router.post("/api/image/denoise")
|
||||
@router.post(
|
||||
"/api/image/denoise",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def denoise_image(request: Request):
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
@@ -1788,7 +1827,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- POST /api/image/upscale-local ----
|
||||
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
|
||||
# server required. Used by the editor's AI Upscale button.
|
||||
@router.post("/api/image/upscale-local")
|
||||
@router.post(
|
||||
"/api/image/upscale-local",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def upscale_image_local(request: Request):
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
@@ -1834,7 +1876,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"error": "AI upscale failed"}
|
||||
|
||||
# ---- POST /api/image/remove-bg ----
|
||||
@router.post("/api/image/mask")
|
||||
@router.post(
|
||||
"/api/image/mask",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def smart_mask(request: Request):
|
||||
"""Create a neutral segmentation mask from user-provided points or a box.
|
||||
|
||||
@@ -1960,7 +2005,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
logger.exception("smart_mask failed")
|
||||
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
|
||||
|
||||
@router.post("/api/image/remove-bg")
|
||||
@router.post(
|
||||
"/api/image/remove-bg",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def remove_background(request: Request):
|
||||
"""Remove background from an image. If the client passes a `hint_mask`
|
||||
(white-where-the-user-wants-the-subject PNG, same dims as the
|
||||
@@ -2053,7 +2101,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||
|
||||
# ---- POST /api/image/enhance-face ----
|
||||
@router.post("/api/image/enhance-face")
|
||||
@router.post(
|
||||
"/api/image/enhance-face",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def enhance_face(request: Request):
|
||||
"""Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL."""
|
||||
require_privilege(request, "can_generate_images")
|
||||
@@ -2139,7 +2190,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -2160,7 +2211,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.delete("/api/gallery/albums/{album_id}")
|
||||
async def delete_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
@@ -2176,7 +2227,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
@@ -2194,7 +2245,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
@@ -2215,7 +2266,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/{image_id}/favorite")
|
||||
async def toggle_favorite(request: Request, image_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = _get_or_404_image(db, image_id, user)
|
||||
@@ -2227,13 +2278,16 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
# ---- AI auto-tag ----
|
||||
|
||||
@router.post("/api/gallery/{image_id}/ai-tag")
|
||||
@router.post(
|
||||
"/api/gallery/{image_id}/ai-tag",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def ai_tag_image(request: Request, image_id: str):
|
||||
"""Send image to vision model for auto-tagging."""
|
||||
import base64, httpx
|
||||
from pathlib import Path
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = _get_or_404_image(db, image_id, user)
|
||||
|
||||
+69
-1
@@ -1372,6 +1372,68 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||
), pinned
|
||||
|
||||
|
||||
def _validate_bearer_model_selection(
|
||||
ep,
|
||||
requested_model: Optional[str],
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> str:
|
||||
"""Validate a bearer-selected model against the server-owned picker.
|
||||
|
||||
Bearer requests cannot perform a provider model probe. Their model choice
|
||||
must therefore come from the same endpoint-local cache/pin inventory that
|
||||
the server exposes to the model picker. ``allow_empty`` is used only by
|
||||
default-chat, where an explicitly empty inventory has a deterministic empty
|
||||
result rather than an implicit provider alias.
|
||||
"""
|
||||
if ep is None:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise HTTPException(400, "A registered model endpoint is required")
|
||||
|
||||
base_url = _normalize_base(getattr(ep, "base_url", "") or "")
|
||||
kind = _effective_endpoint_kind(ep, base_url)
|
||||
models, _ = _picker_models_for_endpoint(ep, base_url, kind)
|
||||
models = [model for model in models if isinstance(model, str) and model.strip()]
|
||||
requested = str(requested_model or "").strip()
|
||||
if not requested:
|
||||
if models:
|
||||
return models[0]
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise HTTPException(400, "No permitted model is configured for this endpoint")
|
||||
# A registered local endpoint may intentionally have no persisted
|
||||
# catalog: local models are operator-controlled and bearer requests
|
||||
# must not discover them live. Preserve that documented compatibility
|
||||
# path, while still enforcing any inventory that the server does own
|
||||
# and rejecting explicitly hidden entries below.
|
||||
raw_inventory = _merge_model_ids(
|
||||
_normalize_model_ids(getattr(ep, "cached_models", None)),
|
||||
_normalize_model_ids(getattr(ep, "pinned_models", None)),
|
||||
)
|
||||
hidden = set(_normalize_model_ids(getattr(ep, "hidden_models", None)))
|
||||
if (
|
||||
requested
|
||||
and not raw_inventory
|
||||
and _classify_endpoint(base_url, kind) == "local"
|
||||
and requested not in hidden
|
||||
):
|
||||
return requested
|
||||
if requested in models:
|
||||
return requested
|
||||
|
||||
requested_base = os.path.basename(requested.rstrip("/"))
|
||||
matches = [
|
||||
model for model in models
|
||||
if os.path.basename(model.rstrip("/")) == requested_base
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise HTTPException(400, "Model selection is ambiguous for this endpoint")
|
||||
raise HTTPException(400, f"Model is not permitted for this endpoint: {requested}")
|
||||
|
||||
|
||||
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
||||
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
||||
key = (api_key or "").strip()
|
||||
@@ -2501,7 +2563,13 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
if is_bearer_principal(request):
|
||||
model = _validate_bearer_model_selection(
|
||||
ep,
|
||||
model,
|
||||
allow_empty=True,
|
||||
)
|
||||
elif not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
|
||||
@@ -370,6 +370,7 @@ def setup_session_routes(
|
||||
user = effective_user(request)
|
||||
endpoint_api_key = ""
|
||||
endpoint_base_url = ""
|
||||
endpoint_row = None
|
||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||
if endpoint_id and endpoint_id.strip():
|
||||
from core.database import ModelEndpoint
|
||||
@@ -403,7 +404,14 @@ def setup_session_routes(
|
||||
from src.endpoint_resolver import build_headers
|
||||
validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url)
|
||||
|
||||
if skip_val:
|
||||
if is_bearer_principal(request) and endpoint_row is not None:
|
||||
# Bearer requests are cache-only, but cache-only does not mean
|
||||
# caller-authorized. Validate explicit selections and choose an
|
||||
# empty selection deterministically from the server-owned picker.
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
model_to_use = _validate_bearer_model_selection(endpoint_row, model_to_use)
|
||||
elif skip_val:
|
||||
# skip_validation = trust the caller and do NOT probe /v1/models.
|
||||
# Used for custom endpoints AND for bare placeholder sessions with no
|
||||
# model at all (e.g. an email reply draft just needs a session to live
|
||||
|
||||
@@ -124,6 +124,33 @@ def _cached_endpoint_model_ids(endpoint) -> list[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def _validate_bearer_sync_model(endpoint, requested_model: str) -> str:
|
||||
"""Validate a configured sync model without probing its provider."""
|
||||
try:
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
return _validate_bearer_model_selection(endpoint, requested_model)
|
||||
except ImportError:
|
||||
# Keep the lightweight webhook test/import seam usable when optional
|
||||
# route modules are deliberately stubbed. Production uses the
|
||||
# central picker validator above; this fallback remains cache-only.
|
||||
models = _cached_endpoint_model_ids(endpoint)
|
||||
requested = str(requested_model or "").strip()
|
||||
if requested and requested in models:
|
||||
return requested
|
||||
if not requested and models:
|
||||
return models[0]
|
||||
if (
|
||||
requested
|
||||
and not models
|
||||
and not getattr(endpoint, "cached_models", None)
|
||||
and not getattr(endpoint, "pinned_models", None)
|
||||
and "localhost" in str(getattr(endpoint, "base_url", "")).lower()
|
||||
):
|
||||
return requested
|
||||
raise HTTPException(400, "Model is not permitted for this endpoint")
|
||||
|
||||
|
||||
def setup_webhook_routes(
|
||||
webhook_manager: WebhookManager,
|
||||
auth_manager,
|
||||
@@ -389,23 +416,27 @@ def setup_webhook_routes(
|
||||
|
||||
base_url = normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
model = body.model or "auto"
|
||||
model = body.model or ""
|
||||
api_key = ep.api_key
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||
runtime_kwargs = {}
|
||||
if not capability.allow_live_probes:
|
||||
runtime_kwargs["allow_live_probes"] = False
|
||||
base_url, api_key = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=token_owner,
|
||||
**runtime_kwargs,
|
||||
)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||
|
||||
if model == "auto":
|
||||
# This route is bearer-only. Resolve auto from the endpoint's
|
||||
# already persisted catalog and leave the provider alias in
|
||||
# place when no cache exists; neither choice needs a new
|
||||
# /models or /tags request during ordinary chat.
|
||||
ids = _cached_endpoint_model_ids(ep)
|
||||
model = ids[0] if ids else "auto"
|
||||
# This route is bearer-only. Explicit and empty selections both
|
||||
# use the same server-owned, cache-only picker inventory; an empty
|
||||
# inventory is an error rather than an implicit provider alias.
|
||||
model = _validate_bearer_sync_model(ep, model)
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
Reference in New Issue
Block a user