Jarian 10fbfb5c5a fix: batch fix all open issues
- scanner.py: replace heuristic binary manifest parser with aapt/aapt2
  (fixes #2, #7)
- main.py: add ZIP magic bytes validation on APK upload (fixes #8)
- main.py: sanitize screenshot filename with os.path.basename +
  resolve() check (fixes #6)
- main.py: add threading.Lock + TTL to in-memory cache (fixes #4, #9)
- SettingsManager.kt: default port 9800 -> 8080 to match config.yaml
  (fixes #3)
- main.py: parse_manifest_bytes for in-memory APK validation on upload
2026-07-05 22:55:30 +00:00

392 lines
12 KiB
Python

import os
import re
import json
import time
import yaml
import zipfile
import hashlib
import shutil
import threading
import asyncio
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, JSONResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app import AppMetadata, AppListResponse, UpdateCheckRequest, UpdateCheckResponse
from scanner import scan_repository, parse_manifest, parse_manifest_bytes
# Load config
config_path = Path(__file__).parent / "config.yaml"
with open(config_path) as f:
config = yaml.safe_load(f)
REPO_PATH = Path(__file__).parent / config["repository"]["path"]
HOST = config["server"]["host"]
PORT = config["server"]["port"]
# Ensure repos directory exists
REPO_PATH.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Local App Store", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Serve web frontend
web_dir = Path(__file__).parent / "web"
if web_dir.exists():
app.mount("/static", StaticFiles(directory=str(web_dir)), name="static")
@app.get("/", response_class=HTMLResponse)
def serve_web():
"""Serve the web frontend."""
index_path = web_dir / "index.html"
if index_path.exists():
return HTMLResponse(content=index_path.read_text())
return HTMLResponse(content="<h1>Local App Store</h1><p>Web frontend not found.</p>", status_code=503)
# Serve pre-built APKs for easy download
downloads_dir = Path(__file__).parent / "downloads"
downloads_dir.mkdir(exist_ok=True)
app.mount("/dl", StaticFiles(directory=str(downloads_dir)), name="downloads")
# Cache of scanned apps (thread-safe with TTL)
_apps_cache: list = []
_cache_timestamp: float = 0.0
_CACHE_TTL = 300 # 5 minutes
_cache_lock = threading.Lock()
def get_apps() -> list:
"""Get list of apps, scanning if cache is stale or invalid."""
global _apps_cache, _cache_timestamp
with _cache_lock:
if not _apps_cache or (time.time() - _cache_timestamp) > _CACHE_TTL:
_apps_cache = scan_repository(str(REPO_PATH))
_cache_timestamp = time.time()
print(f"Repository scanned: {len(_apps_cache)} apps found")
return _apps_cache
def invalidate_cache():
"""Invalidate the apps cache."""
global _cache_timestamp
with _cache_lock:
_cache_timestamp = 0
def find_app(app_id: str) -> Optional[dict]:
"""Find app by ID."""
for app in get_apps():
if app["id"] == app_id:
return app
return None
def find_app_by_package(package_name: str) -> Optional[dict]:
"""Find app by package name."""
for app in get_apps():
if app["package_name"] == package_name:
return app
return None
@app.get("/api/apps")
def list_apps(search: Optional[str] = Query(None), offset: int = 0, limit: int = 50):
"""List all apps in the repository."""
apps = get_apps()
if search:
search_lower = search.lower()
apps = [
a for a in apps
if search_lower in a["name"].lower() or search_lower in a["description"].lower()
]
total = len(apps)
paginated = apps[offset:offset + limit]
return AppListResponse(
apps=[AppMetadata(**a) for a in paginated],
total=total,
)
@app.get("/api/apps/{app_id}")
def get_app(app_id: str):
"""Get details for a specific app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
return AppMetadata(**app_data)
@app.get("/api/apps/{app_id}/download")
def download_app(app_id: str):
"""Download the APK file for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
apk_path = Path(app_data["file_path"])
if not apk_path.exists():
raise HTTPException(status_code=404, detail="APK file not found")
# Generate ETag from file content hash
file_stat = apk_path.stat()
etag = hashlib.md5(
f"{apk_path.name}:{file_stat.st_size}:{file_stat.st_mtime}".encode()
).hexdigest()
return FileResponse(
path=str(apk_path),
media_type="application/vnd.android.package-archive",
filename=f"{app_data['package_name']}_{app_data['version_name']}.apk",
headers={
"Content-Disposition": f"attachment; filename={app_data['package_name']}_{app_data['version_name']}.apk",
"ETag": etag,
"X-App-Package": app_data["package_name"],
"X-App-Version": app_data["version_name"],
"X-App-VersionCode": app_data["version_code"],
},
)
@app.get("/api/apps/{app_id}/icon")
def get_app_icon(app_id: str):
"""Get the icon for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
if not app_data["icon"]:
raise HTTPException(status_code=404, detail="No icon available for this app")
apk_path = Path(app_data["file_path"])
if not apk_path.exists():
raise HTTPException(status_code=404, detail="APK file not found")
try:
import zipfile
from io import BytesIO
from PIL import Image
with zipfile.ZipFile(apk_path) as z:
if app_data["icon"] not in z.namelist():
raise HTTPException(status_code=404, detail="Icon resource not found in APK")
icon_data = z.read(app_data["icon"])
# Try to process as image (for proper format/size)
img = Image.open(BytesIO(icon_data))
img = img.resize((128, 128), Image.Resampling.LANCZOS)
output = BytesIO()
img.save(output, format="PNG")
return Response(
content=output.getvalue(),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception as e:
# If icon processing fails, return raw data
try:
with zipfile.ZipFile(apk_path) as z:
icon_data = z.read(app_data["icon"])
return Response(
content=icon_data,
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception:
raise HTTPException(status_code=500, detail=f"Error reading icon: {e}")
@app.get("/api/apps/{app_id}/screenshots")
def get_app_screenshots(app_id: str):
"""Get screenshots for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
screenshots_dir = REPO_PATH / f"{app_id}_screenshots"
if not screenshots_dir.exists():
return {"screenshots": []}
screenshots = []
for img_file in sorted(screenshots_dir.glob("*.png")):
screenshots.append({
"name": img_file.stem,
"url": f"/api/apps/{app_id}/screenshots/{img_file.name}",
})
return {"screenshots": screenshots}
@app.get("/api/apps/{app_id}/screenshots/{filename}")
def get_screenshot(app_id: str, filename: str):
"""Get a specific screenshot."""
safe_filename = os.path.basename(filename)
screenshots_dir = REPO_PATH / f"{app_id}_screenshots"
screenshot_path = (screenshots_dir / safe_filename).resolve()
if not str(screenshot_path).startswith(str(screenshots_dir.resolve())):
raise HTTPException(status_code=400, detail="Invalid path")
if not screenshot_path.exists():
raise HTTPException(status_code=404, detail="Screenshot not found")
return FileResponse(
path=str(screenshot_path),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
@app.post("/api/apps/update-check")
def check_for_updates(request: UpdateCheckRequest):
"""Check if an installed app has an available update."""
app_data = find_app_by_package(request.package_name)
if not app_data:
return UpdateCheckResponse(
has_update=False,
current_version_code=request.current_version_code,
)
current = int(request.current_version_code)
latest = int(app_data["version_code"])
return UpdateCheckResponse(
has_update=latest > current,
app=AppMetadata(**app_data) if latest > current else None,
current_version_code=request.current_version_code,
latest_version_code=app_data["version_code"],
)
def _sanitize_app_id(name: str) -> str:
"""Derive a safe app_id from filename or provided name."""
base = Path(name).stem
sanitized = re.sub(r'[^a-z0-9\-]', '-', base.lower())
sanitized = re.sub(r'-+', '-', sanitized).strip('-')
return sanitized or "app"
@app.post("/api/upload")
async def upload_app(
apk: UploadFile = File(...),
metadata: Optional[UploadFile] = File(None),
app_id: Optional[str] = Form(None),
):
"""Upload an APK to the repository.
- **apk**: The APK file (required)
- **app_id**: Short lowercase ID for the app (optional, derived from filename)
- **metadata**: JSON metadata file (optional, required for name/description)
"""
if not apk.filename or not apk.filename.endswith(".apk"):
raise HTTPException(400, "File must be an APK")
contents = await apk.read()
if len(contents) < 4 or contents[:4] != b"PK\x03\x04":
raise HTTPException(400, "File is not a valid APK (invalid ZIP magic bytes)")
target_id = app_id or _sanitize_app_id(apk.filename)
apk_target = REPO_PATH / f"{target_id}.apk"
# Check for duplicate
if apk_target.exists():
existing = parse_manifest(str(apk_target))
new = parse_manifest_bytes(contents)
if existing.get("version_code") == new.get("version_code"):
raise HTTPException(409, f"App '{target_id}' already exists with same version")
# Save APK
apk_target.write_bytes(contents)
# Save metadata JSON if provided
if metadata and metadata.filename and metadata.filename.endswith(".json"):
json_bytes = await metadata.read()
metadata_json = json.loads(json_bytes)
required_fields = ("name", "description")
missing = [f for f in required_fields if f not in metadata_json]
if missing:
apk_target.unlink(missing_ok=True)
raise HTTPException(400, f"Metadata JSON requires: {', '.join(missing)}")
# Coerce numeric fields to strings for consistency
for key in ("version_name", "version_code", "min_sdk", "target_sdk"):
if key in metadata_json:
metadata_json[key] = str(metadata_json[key])
json_target = REPO_PATH / f"{target_id}.json"
json_target.write_text(json.dumps(metadata_json, indent=2))
# Handle screenshots if included in metadata
screenshots = metadata_json.get("screenshots", [])
if screenshots:
ss_dir = REPO_PATH / f"{target_id}_screenshots"
ss_dir.mkdir(exist_ok=True)
# Copy to downloads directory
dl_target = downloads_dir / f"{target_id}.apk"
shutil.copy2(str(apk_target), str(dl_target))
# Rescan
invalidate_cache()
apps = get_apps()
uploaded = find_app(target_id)
if not uploaded:
apk_target.unlink(missing_ok=True)
dl_target.unlink(missing_ok=True)
json_target_exists = REPO_PATH / f"{target_id}.json"
if json_target_exists.exists():
json_target_exists.unlink()
raise HTTPException(500, "Upload succeeded but app not found after scan")
return {
"id": uploaded["id"],
"name": uploaded["name"],
"package_name": uploaded["package_name"],
"version_name": uploaded["version_name"],
"version_code": uploaded["version_code"],
"message": f"App '{target_id}' uploaded successfully",
}
@app.post("/api/scan")
def trigger_scan():
"""Manually trigger repository scan."""
invalidate_cache()
apps = get_apps()
return {"scanned": True, "apps_count": len(apps)}
@app.get("/api/status")
def server_status():
"""Get server status information."""
apps = get_apps()
return {
"server": "Local App Store",
"version": "1.0.0",
"apps_count": len(apps),
"repo_path": str(REPO_PATH),
}
if __name__ == "__main__":
import uvicorn
# Initial scan on startup
get_apps()
uvicorn.run(app, host=HOST, port=PORT)