diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index c048ff5..8b0aa96 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -409,10 +409,75 @@ class _HomePageState extends State with TickerProviderStateMixin { if (!url.contains('token=') && state.token.isNotEmpty) { url = "$url&token=${state.token}"; } - return CircleAvatar( - radius: 20, - backgroundImage: NetworkImage(url), - backgroundColor: Colors.transparent, + // Show default avatar while image downloads and display a progress ring + return SizedBox( + width: 44, + height: 44, + child: Stack( + alignment: Alignment.center, + children: [ + // Default placeholder visible under the image/progress + CircleAvatar( + radius: 20, + backgroundColor: isSelected + ? highlightColor + : defaultColor, + child: Icon(icon, color: AppTheme.primaryBackground), + ), + // Network image with loading and error handling + ClipOval( + child: Image.network( + url, + width: 40, + height: 40, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + final expected = + loadingProgress.expectedTotalBytes; + final loaded = + loadingProgress.cumulativeBytesLoaded; + double? value; + if (expected != null && expected > 0) { + value = loaded / expected; + } + return SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + // transparent circle so placeholder remains visible + CircleAvatar( + radius: 20, + backgroundColor: Colors.transparent, + ), + SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + value: value, + strokeWidth: 2.5, + valueColor: + AlwaysStoppedAnimation( + AppTheme.accentColor, + ), + backgroundColor: AppTheme.secondaryText + .withValues(alpha: 0.12), + ), + ), + ], + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + // keep placeholder visible on error + return const SizedBox.shrink(); + }, + ), + ), + ], + ), ); } return CircleAvatar( diff --git a/go_cloud/internal/config/config.go b/go_cloud/internal/config/config.go index 3b361ea..d305d40 100644 --- a/go_cloud/internal/config/config.go +++ b/go_cloud/internal/config/config.go @@ -3,41 +3,46 @@ package config import ( "log" "os" + "strconv" ) type Config struct { - ServerAddr string - DatabaseURL string - OIDCIssuerURL string - OIDCRedirectURL string - OIDCClientID string - OIDCClientSecret string - JWTSecret string - NextcloudURL string - NextcloudUser string - NextcloudPass string - NextcloudBase string - AllowedOrigins string - AvatarCacheDir string + ServerAddr string + DatabaseURL string + OIDCIssuerURL string + OIDCRedirectURL string + OIDCClientID string + OIDCClientSecret string + JWTSecret string + NextcloudURL string + NextcloudUser string + NextcloudPass string + NextcloudBase string + AllowedOrigins string + AvatarCacheDir string + AvatarDownloadTimeoutSeconds int + AvatarDownloadRetries int } func Load() *Config { cfg := &Config{ - ServerAddr: getEnv("SERVER_ADDR", ":8080"), - DatabaseURL: os.Getenv("DATABASE_URL"), - OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), - OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), - OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), - OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), - JWTSecret: os.Getenv("JWT_SECRET"), - NextcloudURL: os.Getenv("NEXTCLOUD_URL"), - NextcloudUser: os.Getenv("NEXTCLOUD_USER"), - NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"), - NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"), - AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"), - AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"), + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), + OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + JWTSecret: os.Getenv("JWT_SECRET"), + NextcloudURL: os.Getenv("NEXTCLOUD_URL"), + NextcloudUser: os.Getenv("NEXTCLOUD_USER"), + NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"), + NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"), + AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"), + AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"), + AvatarDownloadTimeoutSeconds: getEnvInt("AVATAR_DOWNLOAD_TIMEOUT_SECONDS", 10), + AvatarDownloadRetries: getEnvInt("AVATAR_DOWNLOAD_RETRIES", 2), } - log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir) + log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q, AvatarDownloadTimeoutSeconds: %d, AvatarDownloadRetries: %d\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir, cfg.AvatarDownloadTimeoutSeconds, cfg.AvatarDownloadRetries) return cfg } @@ -47,3 +52,12 @@ func getEnv(key, defaultVal string) string { } return defaultVal } + +func getEnvInt(key string, defaultVal int) int { + if val := os.Getenv(key); val != "" { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return defaultVal +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index f29409b..0d71493 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -6,7 +6,6 @@ import ( "context" "database/sql" "encoding/json" - stderrors "errors" "fmt" "io" "log" @@ -4301,7 +4300,7 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D } } - // Download from WebDAV + // Download from WebDAV with retries and backoff client := storage.NewWebDAVClient(cfg) if client == nil { errors.WriteError(w, errors.CodeInternal, "WebDAV client not configured", http.StatusInternalServerError) @@ -4309,31 +4308,58 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D } remotePath := strings.TrimPrefix(*avatarURL, client.BaseURL+"/") - // Use a short timeout to avoid hanging behind proxies; return 404 if WebDAV is slow or times out - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() + // Configure retry & timeout from config + timeoutSeconds := cfg.AvatarDownloadTimeoutSeconds + if timeoutSeconds <= 0 { + timeoutSeconds = 10 + } + retries := cfg.AvatarDownloadRetries + if retries < 0 { + retries = 0 + } + attempts := retries + 1 // total attempts - resp, err := client.Download(ctx, remotePath, "") - if err != nil { - // If download timed out or gateway returned 504, try to serve cached avatar - if strings.Contains(err.Error(), "504") || stderrors.Is(err, context.DeadlineExceeded) { - v = r.URL.Query().Get("v") - if data, ct, cerr := readAvatarCache(cfg, userID.String(), v); cerr == nil { - w.Header().Set("Content-Type", ct) - w.Header().Set("Cache-Control", "public, max-age=300") - w.WriteHeader(http.StatusOK) - w.Write(data) - fmt.Printf("[INFO] Served avatar for user=%s from cache due to WebDAV timeout (v=%s)\n", userID.String(), v) - return - } + var resp *http.Response + var dlErr error + for attempt := 0; attempt < attempts; attempt++ { + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second) + resp, dlErr = client.Download(ctx, remotePath, "") + cancel() + if dlErr == nil { + break + } - fmt.Printf("[ERROR] Avatar download timeout for remotePath=%s: %v\n", remotePath, err) + // If 404 on remote storage, the avatar file truly doesn't exist + if dlErr != nil && strings.Contains(dlErr.Error(), "404") { + fmt.Printf("[ERROR] Avatar not found on storage for remotePath=%s: %v\n", remotePath, dlErr) w.WriteHeader(http.StatusNotFound) return } - errors.LogError(r, err, "Failed to download avatar") - errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + // Log and apply backoff for retryable errors + fmt.Printf("[WARN] Avatar download attempt %d/%d failed for remotePath=%s: %v\n", attempt+1, attempts, remotePath, dlErr) + if attempt < attempts-1 { + // exponential backoff: 500ms, 1s, 2s, ... + backoffMs := 500 * (1 << attempt) + time.Sleep(time.Duration(backoffMs) * time.Millisecond) + } + } + + if dlErr != nil || resp == nil { + // Try to serve the latest cached avatar (if any) as a graceful fallback + if data, ct, cerr := readAvatarCache(cfg, userID.String(), ""); cerr == nil { + w.Header().Set("Content-Type", ct) + w.Header().Set("Cache-Control", "public, max-age=300") + w.WriteHeader(http.StatusOK) + w.Write(data) + fmt.Printf("[INFO] Served latest cached avatar for user=%s after WebDAV failures\n", userID.String()) + return + } + + // No cached avatar available; surface an explicit error (502) so clients can retry + fmt.Printf("[ERROR] Avatar download failed after %d attempts for remotePath=%s: %v\n", attempts, remotePath, dlErr) + w.Header().Set("Retry-After", "30") + errors.WriteError(w, errors.CodeInternal, "Upstream storage unavailable", http.StatusBadGateway) return } defer resp.Body.Close()