From 9a6aa7aadc0facb6c7831e32f2c36c7791343a20 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 23:39:51 +0200 Subject: [PATCH] feat: Client-Download-System + Auto-Upload nach Build Backend: - GET /api/clients - Verfuegbare Clients auflisten (oeffentlich) - GET /api/clients//download - Client herunterladen (oeffentlich) - POST /api/clients//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) --- .env.example | 7 ++ backend/app/api/__init__.py | 2 +- backend/app/api/client_downloads.py | 124 ++++++++++++++++++++++++++++ build.sh | 67 +++++++++++++++ frontend/src/router/index.js | 5 ++ frontend/src/views/ClientsView.vue | 100 ++++++++++++++++++++++ frontend/src/views/LoginView.vue | 8 ++ 7 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/client_downloads.py create mode 100644 frontend/src/views/ClientsView.vue diff --git a/.env.example b/.env.example index 4c5c931..1ff02d8 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,10 @@ MAX_UPLOAD_SIZE_MB=500 # Eigene Subdomain mit HTTPS, z.B. https://office.example.com # JWT wird automatisch vom JWT_SECRET_KEY oben verwendet ONLYOFFICE_URL= + +# Client-Downloads (optional) +# Oeffentliche URL der Cloud-Instanz (fuer den Build-Upload) +CLOUD_URL=https://cloud.example.com +# Token fuer Build-Upload (gleicher wie SECRET_KEY oder eigener) +# Token generieren: python3 -c "import secrets; print(secrets.token_urlsafe(64))" +BUILD_UPLOAD_TOKEN= diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 644583b..1fa22eb 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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 diff --git a/backend/app/api/client_downloads.py b/backend/app/api/client_downloads.py new file mode 100644 index 0000000..c6a9a47 --- /dev/null +++ b/backend/app/api/client_downloads.py @@ -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//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//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 diff --git a/build.sh b/build.sh index 8ef85b5..0e7344d 100755 --- a/build.sh +++ b/build.sh @@ -12,6 +12,9 @@ # ./build.sh all-desktop # Linux + Windows # ./build.sh clean # Build-Cache loeschen # +# Nach dem Build wird der Client automatisch auf den Server hochgeladen +# wenn CLOUD_URL und BUILD_UPLOAD_TOKEN in .env gesetzt sind. +# set -e @@ -29,8 +32,42 @@ info() { echo -e "${GREEN}[BUILD]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } +# Load .env if exists +if [ -f "$SCRIPT_DIR/.env" ]; then + export $(grep -v '^#' "$SCRIPT_DIR/.env" | grep -E '^(CLOUD_URL|BUILD_UPLOAD_TOKEN)=' | xargs) +fi + mkdir -p "$OUTPUT_DIR" +upload_to_server() { + local platform="$1" + local filepath="$2" + + if [ -z "$CLOUD_URL" ] || [ -z "$BUILD_UPLOAD_TOKEN" ]; then + warn "CLOUD_URL oder BUILD_UPLOAD_TOKEN nicht gesetzt - Upload uebersprungen" + warn "Setze beide in .env fuer automatischen Upload" + return + fi + + local filename=$(basename "$filepath") + info "Lade $filename auf $CLOUD_URL hoch..." + + local http_code + http_code=$(curl -s -o /tmp/upload_response.txt -w "%{http_code}" \ + -X POST "$CLOUD_URL/api/clients/$platform/upload" \ + -H "X-Build-Token: $BUILD_UPLOAD_TOKEN" \ + -F "file=@$filepath") + + if [ "$http_code" = "200" ]; then + info "Upload erfolgreich: $filename -> $CLOUD_URL" + cat /tmp/upload_response.txt | python3 -m json.tool 2>/dev/null || cat /tmp/upload_response.txt + else + warn "Upload fehlgeschlagen (HTTP $http_code)" + cat /tmp/upload_response.txt 2>/dev/null + fi + rm -f /tmp/upload_response.txt +} + build_linux() { info "Baue Linux Desktop Client..." cd "$DESKTOP_DIR" @@ -45,6 +82,13 @@ build_linux() { echo 'Linux Build fertig!'" info "Linux Build fertig! Dateien in: $OUTPUT_DIR/" + + # Upload best file (AppImage > deb > binary) + local upload_file="" + for f in "$OUTPUT_DIR"/*.AppImage "$OUTPUT_DIR"/*.deb "$OUTPUT_DIR"/minicloud-sync; do + if [ -f "$f" ]; then upload_file="$f"; break; fi + done + [ -n "$upload_file" ] && upload_to_server "linux" "$upload_file" } build_windows() { @@ -64,6 +108,12 @@ build_windows() { echo 'Windows Build fertig!'" info "Windows Build fertig! Dateien in: $OUTPUT_DIR/" + + local upload_file="" + for f in "$OUTPUT_DIR"/*.msi "$OUTPUT_DIR"/*.exe; do + if [ -f "$f" ]; then upload_file="$f"; break; fi + done + [ -n "$upload_file" ] && upload_to_server "windows" "$upload_file" } build_mac() { @@ -79,6 +129,12 @@ build_mac() { cp -r src-tauri/target/release/bundle/* "$OUTPUT_DIR/" 2>/dev/null info "macOS Build fertig! Dateien in: $OUTPUT_DIR/" + + local upload_file="" + for f in "$OUTPUT_DIR"/*.dmg; do + if [ -f "$f" ]; then upload_file="$f"; break; fi + done + [ -n "$upload_file" ] && upload_to_server "mac" "$upload_file" } build_android() { @@ -98,6 +154,7 @@ build_android() { echo 'Android Build fertig!'" info "Android APK: $OUTPUT_DIR/minicloud.apk" + [ -f "$OUTPUT_DIR/minicloud.apk" ] && upload_to_server "android" "$OUTPUT_DIR/minicloud.apk" } build_ios() { @@ -115,6 +172,12 @@ build_ios() { flutter build ios --release info "iOS Build fertig! Oeffne Xcode fuer Signierung + Archive." + + local upload_file="" + for f in "$MOBILE_DIR"/build/ios/ipa/*.ipa; do + if [ -f "$f" ]; then upload_file="$f"; break; fi + done + [ -n "$upload_file" ] && upload_to_server "ios" "$upload_file" } do_clean() { @@ -172,5 +235,9 @@ case "${1:-help}" in echo "Alle Builds (ausser mac/ios) laufen in Docker - kein lokales" echo "Setup noetig. Output landet in: build-output/" echo "" + echo "Auto-Upload: Wenn CLOUD_URL und BUILD_UPLOAD_TOKEN in .env" + echo "gesetzt sind, wird der Client nach dem Build automatisch auf" + echo "den Server hochgeladen und steht zum Download bereit." + echo "" ;; esac diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 04fc5c4..e7b7576 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -71,6 +71,11 @@ const routes = [ }, ], }, + { + path: '/clients', + name: 'Clients', + component: () => import('../views/ClientsView.vue'), + }, { path: '/share/:token', name: 'Share', diff --git a/frontend/src/views/ClientsView.vue b/frontend/src/views/ClientsView.vue new file mode 100644 index 0000000..0964691 --- /dev/null +++ b/frontend/src/views/ClientsView.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 896cfea..d515045 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -46,6 +46,9 @@ + @@ -68,12 +71,17 @@ const password = ref('') const error = ref('') const loading = ref(false) const registrationAllowed = ref(false) +const hasClients = ref(false) onMounted(async () => { try { const res = await axios.get('/api/auth/registration-status') registrationAllowed.value = res.data.allowed } catch { registrationAllowed.value = false } + try { + const res = await axios.get('/api/clients') + hasClients.value = res.data.has_clients + } catch { hasClients.value = false } }) async function handleLogin() {