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", )