feat: initialize poster studio web service
Verify and deploy poster studio service / verify-deploy (push) Has been cancelled

This commit is contained in:
2026-09-19 01:15:31 +08:00
commit 8f6d5501f9
22 changed files with 662 additions and 0 deletions
+43
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
*.db
data/
+15
View File
@@ -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"]
+9
View File
@@ -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
View File
@@ -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 }
+23
View File
@@ -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"]
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+206
View File
@@ -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
+39
View File
@@ -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
+18
View File
@@ -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()
+20
View File
@@ -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()
+26
View File
@@ -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"}
+56
View File
@@ -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)
+43
View File
@@ -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
+15
View File
@@ -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
+62
View File
@@ -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