fix: require client secrets and sanitize uploaded images
This commit is contained in:
1 parent
1f43b2d065
commit
a07c178fda
7 files changed
+78
-24
No files matched your search
@@ -2,6 +2,7 @@ ADMIN_SECRET=replace-with-a-long-random-secret
|
|||||||
DB_PATH=./data/proxy.db
|
DB_PATH=./data/proxy.db
|
||||||
MAX_UPLOAD_BYTES=52428800
|
MAX_UPLOAD_BYTES=52428800
|
||||||
MAX_IMAGES=8
|
MAX_IMAGES=8
|
||||||
|
MAX_IMAGE_PIXELS=25000000
|
||||||
HTTP_TIMEOUT_SECONDS=300
|
HTTP_TIMEOUT_SECONDS=300
|
||||||
CONNECT_TIMEOUT_SECONDS=15
|
CONNECT_TIMEOUT_SECONDS=15
|
||||||
ALLOWED_ORIGINS=
|
ALLOWED_ORIGINS=
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Provide a small single-host service that routes image-generation requests from i
|
|||||||
|
|
||||||
- Photobooth/installation clients submit a prompt, model, optional parameters, and zero or more images.
|
- Photobooth/installation clients submit a prompt, model, optional parameters, and zero or more images.
|
||||||
- An administrator provisions clients and manages access.
|
- An administrator provisions clients and manages access.
|
||||||
- A client may manage its own upstream keys when it has a client secret.
|
- Every client must have a secret. Generation, job polling, and client-managed key operations require that secret.
|
||||||
- The initial service runs as one Uvicorn process against a local SQLite database on persistent disk.
|
- The initial service runs as one Uvicorn process against a local SQLite database on persistent disk.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -31,7 +31,7 @@ Provide a small single-host service that routes image-generation requests from i
|
|||||||
### Non-functional and security
|
### Non-functional and security
|
||||||
|
|
||||||
- Use async HTTP for all upstream network I/O and a shared bounded connection pool.
|
- Use async HTTP for all upstream network I/O and a shared bounded connection pool.
|
||||||
- Bound aggregate uploaded bytes and image count. Allow only configured raster image MIME types.
|
- Bound aggregate uploaded bytes, image count, and decoded pixel dimensions. Accept only valid JPEG, PNG, and WebP images; re-encode before forwarding to strip metadata and trailing payloads.
|
||||||
- Require an explicit administrator secret at startup; never ship a default secret.
|
- Require an explicit administrator secret at startup; never ship a default secret.
|
||||||
- Store client secrets hashed. Store provider keys only because they are needed for upstream requests; restrict database file access and redact keys from API responses.
|
- Store client secrets hashed. Store provider keys only because they are needed for upstream requests; restrict database file access and redact keys from API responses.
|
||||||
- Scope job reads and client key operations to the authenticated owning client.
|
- Scope job reads and client key operations to the authenticated owning client.
|
||||||
@@ -51,7 +51,7 @@ Provide a small single-host service that routes image-generation requests from i
|
|||||||
- `provider` (optional: `google` or `openrouter`)
|
- `provider` (optional: `google` or `openrouter`)
|
||||||
- `params` (optional JSON object string)
|
- `params` (optional JSON object string)
|
||||||
- `images` (repeated image upload fields)
|
- `images` (repeated image upload fields)
|
||||||
- `x-client-secret` header when a secret is configured for that client
|
- `x-client-secret` header (required)
|
||||||
|
|
||||||
Success is `200` with `provider_used`, `model_used`, `images`, `text`, elapsed time, and failover attempt count. With `?async_mode=true`, success is `200` with `job_id`, status, and poll URL.
|
Success is `200` with `provider_used`, `model_used`, `images`, `text`, elapsed time, and failover attempt count. With `?async_mode=true`, success is `200` with `job_id`, status, and poll URL.
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ Validation errors use FastAPI's `detail` format; upstream failures return `502`
|
|||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
- `clients`: client ID primary key, name, optional client-secret hash, activation flag, creation timestamp.
|
- `clients`: client ID primary key, name, required client-secret hash, activation flag, creation timestamp.
|
||||||
- `provider_keys`: owned by a client with cascading deletion; provider constrained to `google`/`openrouter`; key, priority, activation and failure metadata.
|
- `provider_keys`: owned by a client with cascading deletion; provider constrained to `google`/`openrouter`; key, priority, activation and failure metadata.
|
||||||
- `jobs`: owned by a client with cascading deletion; status, serialized request/result/error and timestamps. On startup, interrupted jobs are marked failed.
|
- `jobs`: owned by a client with cascading deletion; status, serialized request/result/error and timestamps. On startup, interrupted jobs are marked failed.
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ SQLite connections enable foreign keys and busy timeout; WAL is initialized once
|
|||||||
## Architecture and flow
|
## Architecture and flow
|
||||||
|
|
||||||
1. FastAPI validates the multipart request and client authentication.
|
1. FastAPI validates the multipart request and client authentication.
|
||||||
2. Uploaded images are bounded and encoded for the provider's JSON API.
|
2. Uploaded images are bounded, decoded, re-encoded to an allowlisted raster format, then encoded for the provider's JSON API.
|
||||||
3. A route loads the active client's provider keys from SQLite.
|
3. A route loads the active client's provider keys from SQLite.
|
||||||
4. The adapter translates the request to Google or OpenRouter format and sends through shared async `httpx`.
|
4. The adapter translates the request to Google or OpenRouter format and sends through shared async `httpx`.
|
||||||
5. A retryable upstream failure advances to the next configured key; success is normalized into a provider-neutral response.
|
5. A retryable upstream failure advances to the next configured key; success is normalized into a provider-neutral response.
|
||||||
@@ -88,7 +88,7 @@ SQLite connections enable foreign keys and busy timeout; WAL is initialized once
|
|||||||
|
|
||||||
1. Install Python dependencies and set a unique `ADMIN_SECRET`.
|
1. Install Python dependencies and set a unique `ADMIN_SECRET`.
|
||||||
2. Mount a persistent local volume at the configured database path with restrictive permissions.
|
2. Mount a persistent local volume at the configured database path with restrictive permissions.
|
||||||
3. Run one Uvicorn worker. Put TLS and request-size enforcement at Nginx; keep port 8000 private.
|
3. Run one Uvicorn worker as `www-data`, bound to `127.0.0.1:8081`. Put TLS and request-size enforcement at Nginx; keep the application port private.
|
||||||
4. Create clients, add provider credentials, then call `/healthz` and verify one small generation using the intended account/model.
|
4. Create clients, add provider credentials, then call `/healthz` and verify one small generation using the intended account/model.
|
||||||
5. Back up the database securely. Treat backups and job records as secrets and apply retention appropriate to the installation.
|
5. Back up the database securely. Treat backups and job records as secrets and apply retention appropriate to the installation.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ An asynchronous proxy for image generation through Google Gemini or OpenRouter.
|
|||||||
|
|
||||||
## Start locally
|
## Start locally
|
||||||
|
|
||||||
Requirements: Python 3.12 or newer.
|
Requirements: Python 3.12 or newer. Uploaded images are decoded and re-encoded with Pillow; only JPEG, PNG, and WebP are accepted.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
@@ -63,6 +63,7 @@ The built-in `nano-banana` alias defaults to Gemini 2.5 Flash Image. Set `GOOGLE
|
|||||||
| `DB_PATH` | `./data/proxy.db` | SQLite database location. |
|
| `DB_PATH` | `./data/proxy.db` | SQLite database location. |
|
||||||
| `MAX_UPLOAD_BYTES` | `52428800` | Aggregate raw image upload limit. |
|
| `MAX_UPLOAD_BYTES` | `52428800` | Aggregate raw image upload limit. |
|
||||||
| `MAX_IMAGES` | `8` | Maximum reference image count. |
|
| `MAX_IMAGES` | `8` | Maximum reference image count. |
|
||||||
|
| `MAX_IMAGE_PIXELS` | `25000000` | Maximum width × height for each uploaded image. |
|
||||||
| `HTTP_TIMEOUT_SECONDS` | `300` | Overall upstream request timeout. |
|
| `HTTP_TIMEOUT_SECONDS` | `300` | Overall upstream request timeout. |
|
||||||
| `CONNECT_TIMEOUT_SECONDS` | `15` | Upstream connect timeout. |
|
| `CONNECT_TIMEOUT_SECONDS` | `15` | Upstream connect timeout. |
|
||||||
| `GOOGLE_MODEL_NANO_BANANA` | `gemini-2.5-flash-image` | Native Google alias target. |
|
| `GOOGLE_MODEL_NANO_BANANA` | `gemini-2.5-flash-image` | Native Google alias target. |
|
||||||
|
|||||||
+59
-8
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -16,12 +17,14 @@ import aiosqlite
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
LOG = logging.getLogger("phproxy")
|
LOG = logging.getLogger("phproxy")
|
||||||
DB_PATH = os.getenv("DB_PATH", "./data/proxy.db")
|
DB_PATH = os.getenv("DB_PATH", "./data/proxy.db")
|
||||||
ADMIN_SECRET = os.getenv("ADMIN_SECRET", "")
|
ADMIN_SECRET = os.getenv("ADMIN_SECRET", "")
|
||||||
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024)))
|
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024)))
|
||||||
MAX_IMAGES = int(os.getenv("MAX_IMAGES", "8"))
|
MAX_IMAGES = int(os.getenv("MAX_IMAGES", "8"))
|
||||||
|
MAX_IMAGE_PIXELS = int(os.getenv("MAX_IMAGE_PIXELS", "25000000"))
|
||||||
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "300"))
|
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "300"))
|
||||||
CONNECT_TIMEOUT_SECONDS = float(os.getenv("CONNECT_TIMEOUT_SECONDS", "15"))
|
CONNECT_TIMEOUT_SECONDS = float(os.getenv("CONNECT_TIMEOUT_SECONDS", "15"))
|
||||||
MODEL_MAP = {
|
MODEL_MAP = {
|
||||||
@@ -30,7 +33,9 @@ MODEL_MAP = {
|
|||||||
"openrouter": os.getenv("OPENROUTER_MODEL_NANO_BANANA", "google/gemini-2.5-flash-image"),
|
"openrouter": os.getenv("OPENROUTER_MODEL_NANO_BANANA", "google/gemini-2.5-flash-image"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
IMAGE_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
|
||||||
|
IMAGE_FORMAT_TYPES = {mime: image_format for image_format, mime in IMAGE_FORMATS.items()}
|
||||||
|
image_validation_semaphore = asyncio.Semaphore(1)
|
||||||
http_client: httpx.AsyncClient | None = None
|
http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -217,10 +222,48 @@ async def authorize_client(client_id: str, secret: str | None) -> None:
|
|||||||
row = await cur.fetchone()
|
row = await cur.fetchone()
|
||||||
if not row or not row["is_active"]:
|
if not row or not row["is_active"]:
|
||||||
raise HTTPException(status_code=403, detail="Invalid or inactive client")
|
raise HTTPException(status_code=403, detail="Invalid or inactive client")
|
||||||
if row["secret_hash"] and not secret_matches(secret, row["secret_hash"]):
|
if not row["secret_hash"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Client secret must be configured")
|
||||||
|
if not secret_matches(secret, row["secret_hash"]):
|
||||||
raise HTTPException(status_code=401, detail="Invalid client secret")
|
raise HTTPException(status_code=401, detail="Invalid client secret")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_image(raw: bytes, declared_mime: str) -> tuple[bytes, str]:
|
||||||
|
"""Decode and re-encode accepted raster formats, stripping appended payloads/metadata."""
|
||||||
|
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(raw)) as source:
|
||||||
|
image_format = source.format
|
||||||
|
actual_mime = IMAGE_FORMATS.get(image_format or "")
|
||||||
|
if actual_mime is None or actual_mime != declared_mime:
|
||||||
|
raise HTTPException(status_code=415, detail="File content does not match an allowed image type")
|
||||||
|
width, height = source.size
|
||||||
|
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
|
||||||
|
raise HTTPException(status_code=413, detail="Image dimensions exceed configured limits")
|
||||||
|
source.load()
|
||||||
|
image = ImageOps.exif_transpose(source)
|
||||||
|
output = io.BytesIO()
|
||||||
|
if image_format == "JPEG":
|
||||||
|
if image.mode not in ("L", "RGB"):
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.save(output, format="JPEG", quality=95)
|
||||||
|
elif image_format == "PNG":
|
||||||
|
if image.mode not in ("1", "L", "LA", "P", "RGB", "RGBA"):
|
||||||
|
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
else:
|
||||||
|
if image.mode not in ("RGB", "RGBA"):
|
||||||
|
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||||
|
image.save(output, format="WEBP", quality=95)
|
||||||
|
return output.getvalue(), actual_mime
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
|
||||||
|
raise HTTPException(status_code=413, detail="Image dimensions exceed configured limits") from exc
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=415, detail="Uploaded file is not a valid supported image") from exc
|
||||||
|
|
||||||
|
|
||||||
async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], int]:
|
async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], int]:
|
||||||
if len(images) > MAX_IMAGES:
|
if len(images) > MAX_IMAGES:
|
||||||
raise HTTPException(status_code=413, detail=f"At most {MAX_IMAGES} images are allowed")
|
raise HTTPException(status_code=413, detail=f"At most {MAX_IMAGES} images are allowed")
|
||||||
@@ -228,7 +271,7 @@ async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], i
|
|||||||
encoded: list[dict[str, str]] = []
|
encoded: list[dict[str, str]] = []
|
||||||
for image in images:
|
for image in images:
|
||||||
mime = (image.content_type or "").lower()
|
mime = (image.content_type or "").lower()
|
||||||
if mime not in ALLOWED_IMAGE_TYPES:
|
if mime not in IMAGE_FORMAT_TYPES:
|
||||||
raise HTTPException(status_code=415, detail=f"Unsupported image type: {mime or 'unknown'}")
|
raise HTTPException(status_code=415, detail=f"Unsupported image type: {mime or 'unknown'}")
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
while chunk := await image.read(1024 * 1024):
|
while chunk := await image.read(1024 * 1024):
|
||||||
@@ -237,8 +280,14 @@ async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], i
|
|||||||
raise HTTPException(status_code=413, detail="Uploaded images exceed configured limit")
|
raise HTTPException(status_code=413, detail="Uploaded images exceed configured limit")
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
raw = b"".join(chunks)
|
raw = b"".join(chunks)
|
||||||
if raw:
|
if not raw:
|
||||||
encoded.append({"mime_type": mime, "b64": base64.b64encode(raw).decode("ascii")})
|
raise HTTPException(status_code=415, detail="Uploaded image is empty")
|
||||||
|
async with image_validation_semaphore:
|
||||||
|
sanitized, actual_mime = await asyncio.to_thread(sanitize_image, raw, mime)
|
||||||
|
if total - len(raw) + len(sanitized) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Validated images exceed configured upload limit")
|
||||||
|
total += len(sanitized) - len(raw)
|
||||||
|
encoded.append({"mime_type": actual_mime, "b64": base64.b64encode(sanitized).decode("ascii")})
|
||||||
await image.close()
|
await image.close()
|
||||||
return encoded, total
|
return encoded, total
|
||||||
|
|
||||||
@@ -633,11 +682,13 @@ async def admin_generations(limit: int = Query(50, ge=1, le=200),
|
|||||||
|
|
||||||
@app.post("/api/admin/client")
|
@app.post("/api/admin/client")
|
||||||
async def add_client(client_id: str = Form(...), name: str = Form(...),
|
async def add_client(client_id: str = Form(...), name: str = Form(...),
|
||||||
client_secret: str = Form(""), x_admin_secret: str | None = Header(None)):
|
client_secret: str = Form(...), x_admin_secret: str | None = Header(None)):
|
||||||
auth_admin(x_admin_secret)
|
auth_admin(x_admin_secret)
|
||||||
if not client_id.strip() or len(client_id) > 100 or not name.strip():
|
if not client_id.strip() or len(client_id) > 100 or not name.strip():
|
||||||
raise HTTPException(status_code=400, detail="client_id and name are required")
|
raise HTTPException(status_code=400, detail="client_id and name are required")
|
||||||
hashed = secret_hash(client_secret) if client_secret else None
|
if len(client_secret) < 16 or len(client_secret) > 256:
|
||||||
|
raise HTTPException(status_code=400, detail="client_secret must be 16 to 256 characters")
|
||||||
|
hashed = secret_hash(client_secret)
|
||||||
async with db() as conn:
|
async with db() as conn:
|
||||||
await conn.execute("INSERT INTO clients(client_id,name,secret_hash,created_at) VALUES(?,?,?,?) "
|
await conn.execute("INSERT INTO clients(client_id,name,secret_hash,created_at) VALUES(?,?,?,?) "
|
||||||
"ON CONFLICT(client_id) DO UPDATE SET name=excluded.name,secret_hash=excluded.secret_hash",
|
"ON CONFLICT(client_id) DO UPDATE SET name=excluded.name,secret_hash=excluded.secret_hash",
|
||||||
@@ -705,7 +756,7 @@ async def portal_ui():
|
|||||||
<section class=panel><h2>Execution overview</h2><div id=summary class=cards></div><h3>By client</h3><div id=by-client></div></section>
|
<section class=panel><h2>Execution overview</h2><div id=summary class=cards></div><h3>By client</h3><div id=by-client></div></section>
|
||||||
<section class=panel><h2>Recent generations</h2><div class=table-wrap id=history></div></section>
|
<section class=panel><h2>Recent generations</h2><div class=table-wrap id=history></div></section>
|
||||||
<section class=panel><h2>Clients and provider keys</h2><pre id=o></pre></section>
|
<section class=panel><h2>Clients and provider keys</h2><pre id=o></pre></section>
|
||||||
<section class=panel><h2>Create or update client</h2><form id=c><input name=client_id placeholder='Client ID' required><input name=name placeholder=Name required><input name=client_secret placeholder='Optional client secret'><button>Create client</button></form>
|
<section class=panel><h2>Create or update client</h2><form id=c><input name=client_id placeholder='Client ID' required><input name=name placeholder=Name required><input name=client_secret placeholder='Client secret (16+ characters)' minlength=16 maxlength=256 required><button>Create or update client</button></form>
|
||||||
<h2>Add provider key</h2><form id=k><input name=client_id placeholder='Client ID' required><select name=provider><option>google</option><option>openrouter</option></select><input name=api_key placeholder='Provider API key' required><input name=priority type=number value=1 min=1><button>Add key</button></form></section>
|
<h2>Add provider key</h2><form id=k><input name=client_id placeholder='Client ID' required><select name=provider><option>google</option><option>openrouter</option></select><input name=api_key placeholder='Provider API key' required><input name=priority type=number value=1 min=1><button>Add key</button></form></section>
|
||||||
<script>
|
<script>
|
||||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ proxy_read_timeout 300s;
|
|||||||
send_timeout 300s;
|
send_timeout 300s;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
# phproxy API
|
# phproxy API
|
||||||
|
|
||||||
Base URL on this machine's Tailscale network: `http://100.67.41.125:8080`
|
Production base URL: `https://ph_proxy.sokaris.link` (local development uses `http://127.0.0.1:8000`).
|
||||||
|
|
||||||
The service accepts `multipart/form-data` for generation. Management routes also accept form data. Responses are JSON unless noted. The examples use `curl`; an agent can translate them to Python `requests` or `httpx`.
|
The service accepts `multipart/form-data` for generation. Management routes also accept form data. Responses are JSON unless noted. The examples use `curl`; an agent can translate them to Python `requests` or `httpx`.
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ There are two kinds of credentials:
|
|||||||
- **Admin secret**: configured by the service operator. Send it as `x-admin-secret` to administrator endpoints, or `auth-secret` to provider-key endpoints.
|
- **Admin secret**: configured by the service operator. Send it as `x-admin-secret` to administrator endpoints, or `auth-secret` to provider-key endpoints.
|
||||||
- **Client secret**: configured when creating a client. Send it as `x-client-secret` when generating or polling. It can also be used as `auth-secret` for that client's provider-key endpoints.
|
- **Client secret**: configured when creating a client. Send it as `x-client-secret` when generating or polling. It can also be used as `auth-secret` for that client's provider-key endpoints.
|
||||||
|
|
||||||
For the current local development server, the temporary admin secret is `local-development-admin-secret`. Replace it before using the service with real API keys or outside a trusted development tailnet.
|
Set `ADMIN_SECRET` in the service environment. Do not commit it or include it in client scripts.
|
||||||
|
|
||||||
Create a client:
|
Create a client:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
BASE_URL=http://100.67.41.125:8080
|
BASE_URL=https://ph_proxy.sokaris.link
|
||||||
ADMIN_SECRET=local-development-admin-secret
|
ADMIN_SECRET='value-from-the-service-environment'
|
||||||
|
|
||||||
curl -sS -X POST "$BASE_URL/api/admin/client" \
|
curl -sS -X POST "$BASE_URL/api/admin/client" \
|
||||||
-H "x-admin-secret: $ADMIN_SECRET" \
|
-H "x-admin-secret: $ADMIN_SECRET" \
|
||||||
@@ -58,7 +58,7 @@ The same endpoint may be authorized with the owning client's secret instead of t
|
|||||||
| `params` | No | JSON object encoded as a string. Defaults to `{}`. |
|
| `params` | No | JSON object encoded as a string. Defaults to `{}`. |
|
||||||
| `images` | No | Repeat this multipart field for each reference image. |
|
| `images` | No | Repeat this multipart field for each reference image. |
|
||||||
|
|
||||||
Send `x-client-secret` if the client has a secret configured. The service accepts JPEG, PNG, WebP, and GIF images, up to 8 files and 50 MiB aggregate by default. Limits can be changed by the operator.
|
`x-client-secret` is required for every generation and job-poll request. Clients without a configured secret are rejected. Uploads accept JPEG, PNG, or WebP only (not GIF or SVG), up to 8 files and 50 MiB aggregate by default. The service decodes and re-encodes each image before forwarding it, stripping metadata and non-image payloads, and rejects malformed images, mismatched MIME types, and images larger than 25 megapixels by default. Operators can tune the limits.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sS -X POST "$BASE_URL/v1/generate" \
|
curl -sS -X POST "$BASE_URL/v1/generate" \
|
||||||
@@ -150,7 +150,7 @@ Provider APIs may reject unsupported parameters or model IDs. Configure exact ID
|
|||||||
| `GET /ui` | Network access control | Basic HTML administration page. |
|
| `GET /ui` | Network access control | Basic HTML administration page. |
|
||||||
| `GET /api/admin/state` | `x-admin-secret` | Lists clients and keys with only a short key preview. |
|
| `GET /api/admin/state` | `x-admin-secret` | Lists clients and keys with only a short key preview. |
|
||||||
| `GET /api/admin/generations?limit=100` | `x-admin-secret` | Returns aggregate totals, per-client totals, and recent generation timing/cost records (limit 1–200). |
|
| `GET /api/admin/generations?limit=100` | `x-admin-secret` | Returns aggregate totals, per-client totals, and recent generation timing/cost records (limit 1–200). |
|
||||||
| `POST /api/admin/client` | `x-admin-secret` | Form fields: `client_id`, `name`, optional `client_secret`; creates or updates a client. |
|
| `POST /api/admin/client` | `x-admin-secret` | Form fields: `client_id`, `name`, and required `client_secret` (16–256 characters); creates or updates a client. |
|
||||||
| `POST /api/keys` | `auth-secret` | Form fields: `client_id`, `provider`, `api_key`, optional `priority`. Admin or owning client may add keys. |
|
| `POST /api/keys` | `auth-secret` | Form fields: `client_id`, `provider`, `api_key`, optional `priority`. Admin or owning client may add keys. |
|
||||||
| `DELETE /api/keys/{key_id}` | `auth-secret` | Deletes a key owned by the authenticated admin/client. |
|
| `DELETE /api/keys/{key_id}` | `auth-secret` | Deletes a key owned by the authenticated admin/client. |
|
||||||
|
|
||||||
@@ -162,10 +162,10 @@ Validation and authorization failures use FastAPI's `{"detail":"..."}` response
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `400` | Invalid parameters, provider, or missing provider keys. |
|
| `400` | Invalid parameters, provider, or missing provider keys. |
|
||||||
| `401` | Missing or invalid admin/client secret. |
|
| `401` | Missing or invalid admin/client secret. |
|
||||||
| `403` | Client is unknown or inactive. |
|
| `403` | Client is unknown, inactive, or has no secret configured. |
|
||||||
| `404` | Client/key/job was not found, or the job belongs to another client. |
|
| `404` | Client/key/job was not found, or the job belongs to another client. |
|
||||||
| `413` | Request or image upload exceeds configured limits. |
|
| `413` | Request or image upload exceeds configured limits. |
|
||||||
| `415` | Unsupported image MIME type. |
|
| `415` | Unsupported, malformed, or MIME-mismatched image. |
|
||||||
| `502` | Upstream failure after applicable failover attempts. Response contains status metadata, not upstream response bodies. |
|
| `502` | Upstream failure after applicable failover attempts. Response contains status metadata, not upstream response bodies. |
|
||||||
|
|
||||||
## Notes for test scripts
|
## Notes for test scripts
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"aiosqlite>=0.20,<1",
|
"aiosqlite>=0.20,<1",
|
||||||
"uvicorn[standard]>=0.30,<1",
|
"uvicorn[standard]>=0.30,<1",
|
||||||
"python-multipart>=0.0.9,<1",
|
"python-multipart>=0.0.9,<1",
|
||||||
|
"Pillow>=10.4,<13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
Reference in new issue
Block a user