| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- import hashlib
- import re
- import zipfile
- from datetime import datetime, timezone
- from io import BytesIO
- from typing import Optional
- import yaml
- from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
- from pydantic import BaseModel
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from ..auth import create_token, get_current_admin, verify_password
- from ..config import get_settings
- from ..database import get_db
- from ..models import Admin, App, AppCategoryMap, AppVersion, Category, Developer
- router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
- APP_UID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]$")
- class LoginRequest(BaseModel):
- username: str
- password: str
- class LoginResponse(BaseModel):
- token: str
- username: str
- class AppUploadResponse(BaseModel):
- app_uid: str
- name: str
- version: str
- type: str
- category: Optional[str]
- icon: Optional[str]
- description: Optional[str]
- storage_url: str
- message: str
- @router.post("/login", response_model=LoginResponse)
- async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
- result = await db.execute(
- select(Admin).where(Admin.username == req.username, Admin.is_active == True)
- )
- admin = result.scalar_one_or_none()
- if not admin or not verify_password(req.password, admin.password_hash):
- raise HTTPException(status_code=401, detail="Invalid credentials")
- admin.last_login_at = datetime.now(timezone.utc)
- await db.commit()
- token = create_token(admin.id, admin.username)
- return LoginResponse(token=token, username=admin.username)
- def _get_localized(value, lang: str = "zh-CN") -> str:
- if isinstance(value, dict):
- return value.get(lang) or value.get("en-US") or next(iter(value.values()), "")
- return str(value) if value else ""
- @router.post("/apps/upload", response_model=AppUploadResponse)
- async def upload_app(
- file: UploadFile = File(...),
- admin: Admin = Depends(get_current_admin),
- db: AsyncSession = Depends(get_db),
- ):
- if not file.filename or not file.filename.endswith(".zip"):
- raise HTTPException(status_code=400, detail="Only .zip files allowed")
- content = await file.read()
- file_hash = hashlib.sha256(content).hexdigest()
- file_size = len(content)
- try:
- with zipfile.ZipFile(BytesIO(content)) as zf:
- yml_name = next((n for n in zf.namelist() if n.endswith("app.yml")), None)
- if not yml_name:
- raise HTTPException(status_code=400, detail="app.yml not found in zip")
- manifest = yaml.safe_load(zf.read(yml_name))
- except zipfile.BadZipFile:
- raise HTTPException(status_code=400, detail="Invalid zip file")
- app_uid = manifest.get("id")
- version_str = manifest.get("version")
- if not app_uid or not version_str:
- raise HTTPException(status_code=400, detail="app.yml missing id or version")
- if not APP_UID_PATTERN.match(app_uid):
- raise HTTPException(status_code=400, detail="Invalid app_uid format")
- settings = get_settings()
- storage_dir = settings.storage_path / "apps" / app_uid
- storage_dir.mkdir(parents=True, exist_ok=True)
- zip_filename = f"{app_uid}-v{version_str}.zip"
- zip_path = storage_dir / zip_filename
- zip_path.write_bytes(content)
- storage_url = f"{settings.storage_base_url}/apps/{app_uid}/{zip_filename}"
- result = await db.execute(select(App).where(App.app_uid == app_uid))
- app = result.scalar_one_or_none()
- app_name = _get_localized(manifest.get("name"))
- app_type = manifest.get("type", "community")
- short_desc = _get_localized(manifest.get("description"))
- icon = manifest.get("icon")
- category_slug = manifest.get("category")
- if not app:
- dev_result = await db.execute(select(Developer).where(Developer.verified == True).limit(1))
- developer = dev_result.scalar_one_or_none()
- app = App(
- app_uid=app_uid,
- name=app_name,
- type=app_type,
- developer_id=developer.id if developer else None,
- short_description=short_desc,
- status="published",
- publish_time=datetime.now(timezone.utc),
- )
- db.add(app)
- await db.flush()
- if category_slug:
- cat_result = await db.execute(select(Category).where(Category.slug == category_slug))
- category = cat_result.scalar_one_or_none()
- if category:
- db.add(AppCategoryMap(app_id=app.id, category_id=category.id))
- else:
- app.name = app_name
- app.short_description = short_desc
- version_result = await db.execute(
- select(AppVersion).where(AppVersion.app_id == app.id, AppVersion.version == version_str)
- )
- existing_version = version_result.scalar_one_or_none()
- if existing_version:
- existing_version.storage_url = storage_url
- existing_version.checksum_sha256 = file_hash
- existing_version.size_bytes = file_size
- existing_version.manifest = manifest
- existing_version.status = "published"
- existing_version.published_at = datetime.now(timezone.utc)
- version = existing_version
- else:
- version = AppVersion(
- app_id=app.id,
- version=version_str,
- storage_url=storage_url,
- checksum_sha256=file_hash,
- size_bytes=file_size,
- manifest=manifest,
- status="published",
- published_at=datetime.now(timezone.utc),
- )
- db.add(version)
- await db.flush()
- app.current_version_id = version.id
- await db.commit()
- return AppUploadResponse(
- app_uid=app_uid,
- name=app_name,
- version=version_str,
- type=app_type,
- category=category_slug,
- icon=icon,
- description=short_desc,
- storage_url=storage_url,
- message="App uploaded successfully",
- )
|