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:
parent
3ed5adc1e8
commit
9a6aa7aadc
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
67
build.sh
67
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
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ const routes = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
name: 'Clients',
|
||||
component: () => import('../views/ClientsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/share/:token',
|
||||
name: 'Share',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div class="clients-container">
|
||||
<div class="clients-card">
|
||||
<div class="clients-header">
|
||||
<i class="pi pi-cloud" style="font-size: 2rem; color: var(--p-primary-color)"></i>
|
||||
<h1>Mini-Cloud Clients</h1>
|
||||
<p>Lade den Sync-Client fuer dein Geraet herunter</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner"></i> Laden...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!clients.length" class="empty">
|
||||
<p>Noch keine Clients verfuegbar.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="clients-grid">
|
||||
<div v-for="client in clients" :key="client.platform" class="client-card">
|
||||
<div class="client-icon">
|
||||
<i :class="'pi ' + platformIcon(client.platform)"></i>
|
||||
</div>
|
||||
<h3>{{ client.name }}</h3>
|
||||
<p class="client-meta">{{ client.filename }} ({{ formatSize(client.size) }})</p>
|
||||
<Button :label="'Download ' + client.name" icon="pi pi-download"
|
||||
@click="downloadClient(client)" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clients-footer">
|
||||
<router-link to="/login">Zurueck zur Anmeldung</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const clients = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const platformIcons = {
|
||||
linux: 'pi-desktop',
|
||||
windows: 'pi-desktop',
|
||||
mac: 'pi-desktop',
|
||||
android: 'pi-mobile',
|
||||
ios: 'pi-mobile',
|
||||
}
|
||||
|
||||
function platformIcon(platform) {
|
||||
return platformIcons[platform] || 'pi-download'
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0; let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function downloadClient(client) {
|
||||
window.location.href = `/api/clients/${client.platform}/download`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/clients')
|
||||
clients.value = res.data.clients
|
||||
} catch { clients.value = [] }
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clients-container {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-surface-50); padding: 1rem;
|
||||
}
|
||||
.clients-card {
|
||||
background: var(--p-surface-0); border-radius: 12px; padding: 2.5rem;
|
||||
max-width: 700px; width: 100%; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.clients-header { text-align: center; margin-bottom: 2rem; }
|
||||
.clients-header h1 { font-size: 1.5rem; margin: 0.5rem 0 0.25rem; }
|
||||
.clients-header p { color: var(--p-text-muted-color); margin: 0; }
|
||||
.clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
.client-card {
|
||||
border: 1px solid var(--p-surface-200); border-radius: 8px; padding: 1.25rem; text-align: center;
|
||||
}
|
||||
.client-icon { font-size: 2rem; color: var(--p-primary-color); margin-bottom: 0.5rem; }
|
||||
.client-card h3 { margin: 0 0 0.25rem; font-size: 1rem; }
|
||||
.client-meta { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 1rem; }
|
||||
.loading, .empty { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
|
||||
.clients-footer { text-align: center; margin-top: 1.5rem; font-size: 0.875rem; }
|
||||
.clients-footer a { color: var(--p-primary-color); text-decoration: none; }
|
||||
</style>
|
||||
|
|
@ -46,6 +46,9 @@
|
|||
<div v-if="registrationAllowed" class="auth-footer">
|
||||
<router-link to="/register">Noch kein Konto? Registrieren</router-link>
|
||||
</div>
|
||||
<div v-if="hasClients" class="auth-footer">
|
||||
<router-link to="/clients"><i class="pi pi-download"></i> Desktop & Mobile Clients herunterladen</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue