feat: initialize poster studio web service
Verify and deploy poster studio service / verify-deploy (push) Has been cancelled
Verify and deploy poster studio service / verify-deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
name: Verify and deploy poster studio service
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency: { group: poster-studio-production, cancel-in-progress: true }
|
||||
jobs:
|
||||
verify-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container: { image: "docker:27-cli" }
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SOURCE_SERVER: ${{ gitea.server_url }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.repository }}
|
||||
SOURCE_REVISION: ${{ gitea.sha }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
|
||||
steps:
|
||||
- name: Fetch exact revision
|
||||
env: { SOURCE_TOKEN: "${{ secrets.GITEA_TOKEN || gitea.token }}" }
|
||||
run: |
|
||||
set -eu
|
||||
apk add --no-cache python3
|
||||
python3 - <<'PY'
|
||||
import io,os,shutil,tarfile,tempfile,urllib.request
|
||||
from pathlib import Path
|
||||
url=f"{os.environ['SOURCE_SERVER'].rstrip('/')}/api/v1/repos/{os.environ['SOURCE_REPOSITORY']}/archive/{os.environ['SOURCE_REVISION']}.tar.gz"
|
||||
request=urllib.request.Request(url,headers={'Authorization':'token '+os.environ['SOURCE_TOKEN']})
|
||||
with urllib.request.urlopen(request,timeout=60) as response:data=response.read()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:archive.extractall(directory,filter='data')
|
||||
shutil.copytree(next(Path(directory).iterdir()),Path.cwd(),dirs_exist_ok=True)
|
||||
PY
|
||||
- run: docker build --target test -t "poster-studio-server:$SOURCE_REVISION-test" .
|
||||
- if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
run: docker build --target runtime -t "poster-studio-server:$SOURCE_REVISION" .
|
||||
- if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$POSTGRES_PASSWORD"
|
||||
test -n "$ADMIN_TOKEN"
|
||||
export DEPLOY_IMAGE="poster-studio-server:$SOURCE_REVISION"
|
||||
sh scripts/deploy.sh
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.db
|
||||
data/
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim-bookworm AS base
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
FROM base AS test
|
||||
RUN pip install . pytest ruff
|
||||
COPY tests ./tests
|
||||
RUN ruff check src tests && pytest -q
|
||||
FROM base AS runtime
|
||||
RUN pip install . && addgroup --system app && adduser --system --ingroup app app && mkdir -p /data/assets && chown -R app:app /data
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=6 CMD ["python","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=2)"]
|
||||
CMD ["uvicorn","poster_studio.main:app","--host","0.0.0.0","--port","8000"]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Poster Studio Server
|
||||
|
||||
这是长期运行的 Web 服务模块,不是供前端直接链接的普通库。它管理编辑器项目、素材、预设、Tag 权限、客户端访问令牌、管理员发布和后续导出任务;交互渲染由 `@entroia/poster-editor` 完成。
|
||||
|
||||
目录查询支持 `all_tags`、`any_tags`、`none_tags`。客户端 Token 只保存 SHA-256 哈希,并可配置允许/禁止 Tag;请求还必须用 `X-Editor-App` 与 Token 的 `app_key` 对应。静态 Token 无法从公开客户端中真正保密,因此这层只用于产品范围、撤销与额度策略,不能作为用户身份。需要防止跨 App 复制时,由业务后端在验证 PK session 后签发短期 capability,并绑定 `app_key + user_id + install_id + scope + exp`,编辑服务验证签名和重放计数。
|
||||
|
||||
Sticker 上传接口会从图片边缘进行连通背景清除,适合纯色或近纯色背景,并保留管理员确认发布流程。复杂照片分割将通过可替换的模型处理器扩展。
|
||||
|
||||
首次部署需要仓库 Secrets:`POSTGRES_PASSWORD` 与 `ADMIN_TOKEN`。
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-bookworm
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: poster_studio
|
||||
POSTGRES_USER: poster_studio
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||
volumes: [poster-studio-postgres:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "pg_isready -U poster_studio -d poster_studio"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
api:
|
||||
image: ${DEPLOY_IMAGE:?Set DEPLOY_IMAGE}
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
|
||||
volumes: [poster-studio-assets:/data/assets]
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://poster_studio:${POSTGRES_PASSWORD}@postgres:5432/poster_studio
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
|
||||
ASSET_ROOT: /data/assets
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
networks:
|
||||
default:
|
||||
public:
|
||||
aliases: [poster-studio-api]
|
||||
volumes:
|
||||
poster-studio-postgres: { name: poster-studio-postgres-data }
|
||||
poster-studio-assets: { name: poster-studio-assets }
|
||||
networks:
|
||||
public: { name: pk-lab-public, external: true }
|
||||
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "poster-studio-server"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2,<3",
|
||||
"psycopg[binary]>=3.2,<4", "pydantic-settings>=2.10,<3",
|
||||
"python-multipart>=0.0.20,<1", "pillow>=11,<13"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["B008"]
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
: "${DEPLOY_IMAGE:?Set DEPLOY_IMAGE}"
|
||||
: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}"
|
||||
: "${ADMIN_TOKEN:?Set ADMIN_TOKEN}"
|
||||
docker network inspect pk-lab-public >/dev/null 2>&1 || docker network create pk-lab-public
|
||||
docker compose -p poster-studio-production up -d --remove-orphans --wait
|
||||
docker compose -p poster-studio-production ps
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,206 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .background import remove_border_background
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import CatalogItem, ClientToken, EditorProject
|
||||
from .security import digest, new_token, require_admin, require_client
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
app_key: str = Field(min_length=1, max_length=80)
|
||||
allow_tags: list[str] = Field(default_factory=list, max_length=100)
|
||||
deny_tags: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
kind: str = Field(pattern="^(filter|text|sticker|layout|brush)$")
|
||||
slug: str = Field(pattern="^[a-z0-9][a-z0-9-]{0,99}$")
|
||||
version: int = Field(default=1, ge=1)
|
||||
title: str = Field(min_length=1, max_length=120)
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list, max_length=100)
|
||||
definition: dict = Field(default_factory=dict)
|
||||
asset_url: str | None = None
|
||||
status: str = Field(default="draft", pattern="^(draft|published|archived)$")
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
owner_id: str
|
||||
source_type: str = "custom"
|
||||
source_id: str | None = None
|
||||
document: dict
|
||||
|
||||
|
||||
class CatalogView(CatalogCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class ProjectView(ProjectCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
revision: int
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
revision: int = Field(ge=1)
|
||||
document: dict
|
||||
|
||||
|
||||
def visible_for(token: ClientToken, tags: list[str]) -> bool:
|
||||
values = set(tags)
|
||||
if set(token.deny_tags or []) & values:
|
||||
return False
|
||||
allowed = set(token.allow_tags or [])
|
||||
return not allowed or bool(allowed & values)
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=list[CatalogView])
|
||||
def catalog(
|
||||
kind: str | None = None,
|
||||
all_tags: list[str] = Query(default=[]),
|
||||
any_tags: list[str] = Query(default=[]),
|
||||
none_tags: list[str] = Query(default=[]),
|
||||
token: ClientToken = Depends(require_client),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
statement = select(CatalogItem).where(CatalogItem.status == "published")
|
||||
if kind:
|
||||
statement = statement.where(CatalogItem.kind == kind)
|
||||
items = db.scalars(statement.order_by(CatalogItem.sort_order, CatalogItem.title)).all()
|
||||
result = []
|
||||
for item in items:
|
||||
tags = set(item.tags or [])
|
||||
if not visible_for(token, item.tags or []):
|
||||
continue
|
||||
if not set(all_tags).issubset(tags):
|
||||
continue
|
||||
if any_tags and not set(any_tags) & tags:
|
||||
continue
|
||||
if set(none_tags) & tags:
|
||||
continue
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/admin/tokens", dependencies=[Depends(require_admin)])
|
||||
def create_token(body: TokenCreate, db: Session = Depends(get_db)):
|
||||
raw = new_token()
|
||||
record = ClientToken(
|
||||
name=body.name,
|
||||
app_key=body.app_key,
|
||||
token_hash=digest(raw),
|
||||
allow_tags=body.allow_tags,
|
||||
deny_tags=body.deny_tags,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return {"id": record.id, "token": raw, "app_key": record.app_key}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/catalog",
|
||||
dependencies=[Depends(require_admin)],
|
||||
status_code=201,
|
||||
response_model=CatalogView,
|
||||
)
|
||||
def create_catalog_item(body: CatalogCreate, db: Session = Depends(get_db)):
|
||||
item = CatalogItem(**body.model_dump())
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/admin/stickers", dependencies=[Depends(require_admin)], status_code=201)
|
||||
async def create_sticker(
|
||||
title: str = Form(...),
|
||||
slug: str = Form(...),
|
||||
tags: str = Form(""),
|
||||
tolerance: int = Form(34),
|
||||
image: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
settings = get_settings()
|
||||
data = await image.read(settings.max_upload_bytes + 1)
|
||||
if len(data) > settings.max_upload_bytes:
|
||||
raise HTTPException(status_code=413, detail="file too large")
|
||||
try:
|
||||
output, width, height = remove_border_background(
|
||||
data, max(0, min(tolerance, 120))
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"background removal failed: {exc}"
|
||||
) from exc
|
||||
root = Path(settings.asset_root)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{slug}.png"
|
||||
(root / filename).write_bytes(output)
|
||||
item = CatalogItem(
|
||||
kind="sticker",
|
||||
slug=slug,
|
||||
version=1,
|
||||
title=title,
|
||||
tags=[tag.strip() for tag in tags.split(",") if tag.strip()],
|
||||
definition={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"backgroundRemoval": "border-color-v1",
|
||||
},
|
||||
asset_url=f"/assets/{filename}",
|
||||
status="draft",
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/projects", status_code=201, response_model=ProjectView)
|
||||
def create_project(
|
||||
body: ProjectCreate,
|
||||
_: ClientToken = Depends(require_client),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if body.document.get("schema") != "entroia.poster-document":
|
||||
raise HTTPException(status_code=422, detail="invalid document schema")
|
||||
project = EditorProject(**body.model_dump())
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}", response_model=ProjectView)
|
||||
def update_project(
|
||||
project_id: str,
|
||||
body: ProjectUpdate,
|
||||
_: ClientToken = Depends(require_client),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = db.get(EditorProject, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="project not found")
|
||||
if project.revision != body.revision:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"current_revision": project.revision}
|
||||
)
|
||||
project.document = body.document
|
||||
project.revision += 1
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections import deque
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageStat
|
||||
|
||||
|
||||
def remove_border_background(data: bytes, tolerance: int = 34) -> tuple[bytes, int, int]:
|
||||
image = Image.open(BytesIO(data)).convert("RGBA")
|
||||
if image.width * image.height > 12_000_000:
|
||||
raise ValueError("image exceeds 12 megapixels")
|
||||
pixels = image.load()
|
||||
corners = Image.new("RGB", (4, 1))
|
||||
corner_values = [pixels[0, 0][:3], pixels[image.width - 1, 0][:3], pixels[0, image.height - 1][:3], pixels[image.width - 1, image.height - 1][:3]]
|
||||
for index, color in enumerate(corner_values):
|
||||
corners.putpixel((index, 0), color)
|
||||
background = tuple(round(value) for value in ImageStat.Stat(corners).mean)
|
||||
seen: set[tuple[int, int]] = set()
|
||||
queue = deque()
|
||||
for x in range(image.width):
|
||||
queue.extend(((x, 0), (x, image.height - 1)))
|
||||
for y in range(image.height):
|
||||
queue.extend(((0, y), (image.width - 1, y)))
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
if (x, y) in seen:
|
||||
continue
|
||||
seen.add((x, y))
|
||||
color = pixels[x, y][:3]
|
||||
distance = sum((color[i] - background[i]) ** 2 for i in range(3)) ** 0.5
|
||||
if distance > tolerance:
|
||||
continue
|
||||
pixels[x, y] = (*color, 0)
|
||||
if x: queue.append((x - 1, y))
|
||||
if x + 1 < image.width: queue.append((x + 1, y))
|
||||
if y: queue.append((x, y - 1))
|
||||
if y + 1 < image.height: queue.append((x, y + 1))
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue(), image.width, image.height
|
||||
@@ -0,0 +1,18 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
database_url: str = "sqlite:///./poster-studio.db"
|
||||
asset_root: Path = Path("./data/assets")
|
||||
admin_token: str = Field(default="", repr=False)
|
||||
max_upload_bytes: int = 15 * 1024 * 1024
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api import router
|
||||
from .config import get_settings
|
||||
from .db import Base, engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
settings = get_settings()
|
||||
settings.asset_root.mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Poster Studio Server", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(router)
|
||||
app.mount("/assets", StaticFiles(directory=get_settings().asset_root, check_dir=False), name="assets")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,56 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def uid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class ClientToken(Base):
|
||||
__tablename__ = "client_tokens"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uid)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
app_key: Mapped[str] = mapped_column(String(80), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
allow_tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
deny_tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
|
||||
class CatalogItem(Base):
|
||||
__tablename__ = "catalog_items"
|
||||
__table_args__ = (UniqueConstraint("kind", "slug", "version", name="uq_catalog_version"),)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uid)
|
||||
kind: Mapped[str] = mapped_column(String(24), index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
title: Mapped[str] = mapped_column(String(120))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
definition: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
asset_url: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(16), default="draft", index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
|
||||
class EditorProject(Base):
|
||||
__tablename__ = "editor_projects"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uid)
|
||||
owner_id: Mapped[str] = mapped_column(String(80), index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(40), default="custom")
|
||||
source_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1)
|
||||
document: Mapped[dict] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
@@ -0,0 +1,43 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import ClientToken
|
||||
|
||||
|
||||
def digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return "pse_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def require_admin(authorization: str = Header(default="")) -> None:
|
||||
expected = get_settings().admin_token
|
||||
supplied = authorization.removeprefix("Bearer ").strip()
|
||||
if not expected or not hmac.compare_digest(supplied, expected):
|
||||
raise HTTPException(status_code=401, detail="admin authorization required")
|
||||
|
||||
|
||||
def require_client(
|
||||
x_editor_token: str = Header(default=""),
|
||||
x_editor_app: str = Header(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClientToken:
|
||||
token = db.scalar(
|
||||
select(ClientToken).where(
|
||||
ClientToken.token_hash == digest(x_editor_token), ClientToken.active.is_(True)
|
||||
)
|
||||
)
|
||||
if token is None:
|
||||
raise HTTPException(status_code=401, detail="valid editor token required")
|
||||
if not x_editor_app or not hmac.compare_digest(x_editor_app, token.app_key):
|
||||
raise HTTPException(status_code=403, detail="editor token is not valid for this app")
|
||||
return token
|
||||
@@ -0,0 +1,11 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: poster-studio-server
|
||||
Version: 0.1.0
|
||||
Requires-Python: >=3.12
|
||||
Requires-Dist: fastapi<1,>=0.116
|
||||
Requires-Dist: uvicorn<1,>=0.35
|
||||
Requires-Dist: sqlalchemy<3,>=2
|
||||
Requires-Dist: psycopg[binary]<4,>=3.2
|
||||
Requires-Dist: pydantic-settings<3,>=2.10
|
||||
Requires-Dist: python-multipart<1,>=0.0.20
|
||||
Requires-Dist: pillow<13,>=11
|
||||
@@ -0,0 +1,16 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/poster_studio/__init__.py
|
||||
src/poster_studio/api.py
|
||||
src/poster_studio/background.py
|
||||
src/poster_studio/config.py
|
||||
src/poster_studio/db.py
|
||||
src/poster_studio/main.py
|
||||
src/poster_studio/models.py
|
||||
src/poster_studio/security.py
|
||||
src/poster_studio_server.egg-info/PKG-INFO
|
||||
src/poster_studio_server.egg-info/SOURCES.txt
|
||||
src/poster_studio_server.egg-info/dependency_links.txt
|
||||
src/poster_studio_server.egg-info/requires.txt
|
||||
src/poster_studio_server.egg-info/top_level.txt
|
||||
tests/test_api.py
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi<1,>=0.116
|
||||
uvicorn<1,>=0.35
|
||||
sqlalchemy<3,>=2
|
||||
psycopg[binary]<4,>=3.2
|
||||
pydantic-settings<3,>=2.10
|
||||
python-multipart<1,>=0.0.20
|
||||
pillow<13,>=11
|
||||
@@ -0,0 +1 @@
|
||||
poster_studio
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./test-poster-studio.db"
|
||||
os.environ["ADMIN_TOKEN"] = "test-admin"
|
||||
|
||||
import pytest
|
||||
|
||||
from poster_studio.db import Base, engine
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_database():
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
@@ -0,0 +1,62 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from poster_studio.main import app
|
||||
|
||||
|
||||
def test_token_catalog_and_project():
|
||||
with TestClient(app) as client:
|
||||
admin = {"Authorization": "Bearer test-admin"}
|
||||
created = client.post(
|
||||
"/api/v1/admin/tokens",
|
||||
headers=admin,
|
||||
json={
|
||||
"name": "hub",
|
||||
"app_key": "hub",
|
||||
"allow_tags": ["pk"],
|
||||
"deny_tags": ["private"],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
token = created.json()["token"]
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/admin/catalog",
|
||||
headers=admin,
|
||||
json={
|
||||
"kind": "brush",
|
||||
"slug": "flowers",
|
||||
"title": "花花笔刷",
|
||||
"tags": ["pk", "floral"],
|
||||
"definition": {"engine": "stamp"},
|
||||
"status": "published",
|
||||
},
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
headers = {"X-Editor-Token": token, "X-Editor-App": "hub"}
|
||||
catalog = client.get(
|
||||
"/api/v1/catalog?all_tags=pk&none_tags=private", headers=headers
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()[0]["slug"] == "flowers"
|
||||
document = {
|
||||
"schema": "entroia.poster-document",
|
||||
"version": 1,
|
||||
"id": "d",
|
||||
"seed": "s",
|
||||
"canvas": {"width": 1080, "height": 1440},
|
||||
"layers": [],
|
||||
}
|
||||
project = client.post(
|
||||
"/api/v1/projects",
|
||||
headers=headers,
|
||||
json={"owner_id": "u", "document": document},
|
||||
)
|
||||
assert project.status_code == 201
|
||||
assert project.json()["revision"] == 1
|
||||
|
||||
wrong_app = client.get(
|
||||
"/api/v1/catalog",
|
||||
headers={"X-Editor-Token": token, "X-Editor-App": "other"},
|
||||
)
|
||||
assert wrong_app.status_code == 403
|
||||
Reference in New Issue
Block a user