commit 8f6d5501f9abf740c57dd61c4c6e17918c216488 Author: Codex Bot Date: Sat Sep 19 01:15:31 2026 +0800 feat: initialize poster studio web service diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e75640b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cba5e58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +*.db +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..09fba93 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..93efea6 --- /dev/null +++ b/README.md @@ -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`。 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..58dae22 --- /dev/null +++ b/compose.yml @@ -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 } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6593f4c --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..5e3a682 --- /dev/null +++ b/scripts/deploy.sh @@ -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 diff --git a/src/poster_studio/__init__.py b/src/poster_studio/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/poster_studio/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/poster_studio/api.py b/src/poster_studio/api.py new file mode 100644 index 0000000..a00abf9 --- /dev/null +++ b/src/poster_studio/api.py @@ -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 diff --git a/src/poster_studio/background.py b/src/poster_studio/background.py new file mode 100644 index 0000000..e1afb0d --- /dev/null +++ b/src/poster_studio/background.py @@ -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 diff --git a/src/poster_studio/config.py b/src/poster_studio/config.py new file mode 100644 index 0000000..88296b5 --- /dev/null +++ b/src/poster_studio/config.py @@ -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() diff --git a/src/poster_studio/db.py b/src/poster_studio/db.py new file mode 100644 index 0000000..e66a929 --- /dev/null +++ b/src/poster_studio/db.py @@ -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() diff --git a/src/poster_studio/main.py b/src/poster_studio/main.py new file mode 100644 index 0000000..760e527 --- /dev/null +++ b/src/poster_studio/main.py @@ -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"} diff --git a/src/poster_studio/models.py b/src/poster_studio/models.py new file mode 100644 index 0000000..09a0f2b --- /dev/null +++ b/src/poster_studio/models.py @@ -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) diff --git a/src/poster_studio/security.py b/src/poster_studio/security.py new file mode 100644 index 0000000..a9f77f8 --- /dev/null +++ b/src/poster_studio/security.py @@ -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 diff --git a/src/poster_studio_server.egg-info/PKG-INFO b/src/poster_studio_server.egg-info/PKG-INFO new file mode 100644 index 0000000..a538641 --- /dev/null +++ b/src/poster_studio_server.egg-info/PKG-INFO @@ -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 diff --git a/src/poster_studio_server.egg-info/SOURCES.txt b/src/poster_studio_server.egg-info/SOURCES.txt new file mode 100644 index 0000000..e027e2d --- /dev/null +++ b/src/poster_studio_server.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/poster_studio_server.egg-info/dependency_links.txt b/src/poster_studio_server.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/poster_studio_server.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/poster_studio_server.egg-info/requires.txt b/src/poster_studio_server.egg-info/requires.txt new file mode 100644 index 0000000..1b26c9a --- /dev/null +++ b/src/poster_studio_server.egg-info/requires.txt @@ -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 diff --git a/src/poster_studio_server.egg-info/top_level.txt b/src/poster_studio_server.egg-info/top_level.txt new file mode 100644 index 0000000..9e1b4c8 --- /dev/null +++ b/src/poster_studio_server.egg-info/top_level.txt @@ -0,0 +1 @@ +poster_studio diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3f986c --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..8789233 --- /dev/null +++ b/tests/test_api.py @@ -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