feat: Client-Download-System + Auto-Upload nach Build

Backend:
- GET /api/clients - Verfuegbare Clients auflisten (oeffentlich)
- GET /api/clients/<platform>/download - Client herunterladen (oeffentlich)
- POST /api/clients/<platform>/upload - Build hochladen (BUILD_UPLOAD_TOKEN)
- Alte Version wird automatisch bei neuem Upload ersetzt
- Plattformen: linux, windows, mac, android, ios

Frontend:
- /clients - Download-Seite mit Grid aller verfuegbaren Clients
- Login-Seite zeigt "Desktop & Mobile Clients herunterladen" Link
  wenn mindestens ein Client verfuegbar ist

build.sh:
- Nach jedem Build wird der Client automatisch auf CLOUD_URL
  hochgeladen (wenn CLOUD_URL + BUILD_UPLOAD_TOKEN in .env gesetzt)
- Bestes Format pro Plattform: AppImage > .deb > Binary (Linux),
  .msi > .exe (Windows), .dmg (Mac), .apk (Android), .ipa (iOS)

.env.example:
- CLOUD_URL: Oeffentliche URL der Cloud-Instanz
- BUILD_UPLOAD_TOKEN: Auth-Token fuer Build-Upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 23:39:51 +02:00
parent 3ed5adc1e8
commit 9a6aa7aadc
7 changed files with 312 additions and 1 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ from flask import Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/api')
from app.api import auth, users, files, calendar, contacts, email, office, passwords, backup # noqa: E402, F401
from app.api import auth, users, files, calendar, contacts, email, office, passwords, backup, client_downloads # noqa: E402, F401
+124
View File
@@ -0,0 +1,124 @@
"""Client download management - upload builds, serve downloads."""
import os
from pathlib import Path
from flask import request, jsonify, send_from_directory, current_app
from app.api import api_bp
# Supported platforms and their file extensions
PLATFORMS = {
'linux': {'name': 'Linux', 'icon': 'pi-desktop', 'extensions': ['.AppImage', '.deb', '']},
'windows': {'name': 'Windows', 'icon': 'pi-microsoft', 'extensions': ['.msi', '.exe']},
'mac': {'name': 'macOS', 'icon': 'pi-apple', 'extensions': ['.dmg']},
'android': {'name': 'Android', 'icon': 'pi-android', 'extensions': ['.apk']},
'ios': {'name': 'iOS', 'icon': 'pi-apple', 'extensions': ['.ipa']},
}
def _clients_dir():
"""Get the client downloads directory."""
base = Path(current_app.config.get('UPLOAD_PATH', '/app/data/files')).parent
d = base / 'client-downloads'
d.mkdir(parents=True, exist_ok=True)
return d
def _verify_build_token():
"""Verify the build upload token from header or query param."""
expected = os.environ.get('BUILD_UPLOAD_TOKEN', '')
if not expected:
return False
token = request.headers.get('X-Build-Token', '') or request.args.get('build_token', '')
return token == expected
# --- Public: list available clients ---
@api_bp.route('/clients', methods=['GET'])
def list_clients():
"""List available client downloads (public, no auth needed)."""
clients_dir = _clients_dir()
available = []
for platform, info in PLATFORMS.items():
platform_dir = clients_dir / platform
if not platform_dir.exists():
continue
files = sorted(platform_dir.iterdir(), key=lambda f: f.stat().st_mtime, reverse=True)
if not files:
continue
# Take the newest file
latest = files[0]
available.append({
'platform': platform,
'name': info['name'],
'icon': info['icon'],
'filename': latest.name,
'size': latest.stat().st_size,
'updated_at': latest.stat().st_mtime,
'download_url': f'/api/clients/{platform}/download',
})
return jsonify({
'clients': available,
'has_clients': len(available) > 0,
}), 200
@api_bp.route('/clients/<platform>/download', methods=['GET'])
def download_client(platform):
"""Download the latest client for a platform (public, no auth)."""
if platform not in PLATFORMS:
return jsonify({'error': 'Unbekannte Plattform'}), 404
clients_dir = _clients_dir()
platform_dir = clients_dir / platform
if not platform_dir.exists():
return jsonify({'error': 'Kein Client fuer diese Plattform verfuegbar'}), 404
files = sorted(platform_dir.iterdir(), key=lambda f: f.stat().st_mtime, reverse=True)
if not files:
return jsonify({'error': 'Kein Client verfuegbar'}), 404
latest = files[0]
return send_from_directory(str(platform_dir), latest.name, as_attachment=True)
# --- Build upload (authenticated with BUILD_UPLOAD_TOKEN) ---
@api_bp.route('/clients/<platform>/upload', methods=['POST'])
def upload_client(platform):
"""Upload a new client build. Authenticated with BUILD_UPLOAD_TOKEN."""
if not _verify_build_token():
return jsonify({'error': 'Ungueltiger Build-Token'}), 403
if platform not in PLATFORMS:
return jsonify({'error': 'Unbekannte Plattform'}), 404
if 'file' not in request.files:
return jsonify({'error': 'Keine Datei gesendet'}), 400
upload = request.files['file']
if not upload.filename:
return jsonify({'error': 'Leerer Dateiname'}), 400
clients_dir = _clients_dir()
platform_dir = clients_dir / platform
platform_dir.mkdir(parents=True, exist_ok=True)
# Remove old files for this platform (keep only latest)
for old_file in platform_dir.iterdir():
old_file.unlink()
dest = platform_dir / upload.filename
upload.save(str(dest))
return jsonify({
'message': f'{PLATFORMS[platform]["name"]} Client hochgeladen',
'filename': upload.filename,
'size': dest.stat().st_size,
}), 200