- 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
392 lines
12 KiB
Python
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) |