diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index aa6d053..c048ff5 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -403,10 +403,15 @@ class _HomePageState extends State with TickerProviderStateMixin { ? BlocBuilder( builder: (context, state) { if (state is AuthAuthenticated && - state.user?.avatarUrl != null) { + state.user?.avatarUrl != null && + state.token.isNotEmpty) { + String url = state.user!.avatarUrl!; + if (!url.contains('token=') && state.token.isNotEmpty) { + url = "$url&token=${state.token}"; + } return CircleAvatar( radius: 20, - backgroundImage: NetworkImage(state.user!.avatarUrl!), + backgroundImage: NetworkImage(url), backgroundColor: Colors.transparent, ); } diff --git a/go_cloud/cmd/api/main.go b/go_cloud/cmd/api/main.go index bbef97d..a6ed390 100644 --- a/go_cloud/cmd/api/main.go +++ b/go_cloud/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/auth" @@ -13,9 +14,43 @@ import ( "go.b0esche.cloud/backend/pkg/jwt" ) +// ensureAvatarCacheDir finds a writable, preferably persistent directory for avatar cache and updates cfg +func ensureAvatarCacheDir(cfg *config.Config) { + candidates := []string{ + cfg.AvatarCacheDir, + "/var/lib/b0esche/avatars", + "./data/avatars", + filepath.Join(os.TempDir(), "b0esche_avatars"), + } + + for _, d := range candidates { + if d == "" { + continue + } + if err := os.MkdirAll(d, 0755); err == nil { + // Try writing a small test file to confirm write permission + testPath := filepath.Join(d, ".write_test") + if err := os.WriteFile(testPath, []byte("ok"), 0644); err == nil { + os.Remove(testPath) + if d != cfg.AvatarCacheDir { + fmt.Printf("[WARN] Avatar cache dir %q not usable, using %q instead. Please set AVATAR_CACHE_DIR to a persistent, writable volume.\n", cfg.AvatarCacheDir, d) + } + cfg.AvatarCacheDir = d + fmt.Printf("[INFO] Avatar cache directory set to %q\n", d) + return + } + } + } + // If none usable, keep configured value and let runtime fallback handle it + fmt.Printf("[WARN] No writable persistent avatar cache directory found; falling back to tmp. Set AVATAR_CACHE_DIR to a persistent path.\n") +} + func main() { cfg := config.Load() + // Ensure avatar cache directory is usable and persistent when possible + ensureAvatarCacheDir(cfg) + dbConn, err := database.Connect(cfg) if err != nil { fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err) diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index a2b9ca0..f29409b 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -55,16 +55,22 @@ func sanitizePath(inputPath string) (string, error) { } // Avatar cache helpers -func avatarCachePath(cfg *config.Config, userID string, ext string) string { +// avatarCachePath builds a path for the avatar cache file for the given user and version (if provided). +func avatarCachePath(cfg *config.Config, userID string, version string, ext string) string { dir := cfg.AvatarCacheDir if dir == "" { dir = "/var/cache/b0esche/avatars" } os.MkdirAll(dir, 0755) + // Filename: . (if version empty, use ) + if version != "" { + return filepath.Join(dir, fmt.Sprintf("%s.%s%s", userID, version, ext)) + } return filepath.Join(dir, fmt.Sprintf("%s%s", userID, ext)) } -func writeAvatarCache(cfg *config.Config, userID string, ext string, data []byte) error { +// writeAvatarCache saves avatar bytes keyed by userID and version (best effort). If version is empty, writes a file without version suffix. +func writeAvatarCache(cfg *config.Config, userID string, version string, ext string, data []byte) error { dir := cfg.AvatarCacheDir if dir == "" { dir = "/var/cache/b0esche/avatars" @@ -78,11 +84,12 @@ func writeAvatarCache(cfg *config.Config, userID string, ext string, data []byte } dir = fallback } - p := filepath.Join(dir, fmt.Sprintf("%s%s", userID, ext)) + p := avatarCachePath(&config.Config{AvatarCacheDir: dir}, userID, version, ext) return os.WriteFile(p, data, 0644) } -func readAvatarCache(cfg *config.Config, userID string) ([]byte, string, error) { +// readAvatarCache attempts to read a cached avatar for a user and optional version. If version is provided, it looks for an exact match; otherwise it returns the latest available cached avatar (if any). +func readAvatarCache(cfg *config.Config, userID string, version string) ([]byte, string, error) { checkDirs := []string{cfg.AvatarCacheDir} if checkDirs[0] == "" { checkDirs[0] = "/var/cache/b0esche/avatars" @@ -95,21 +102,55 @@ func readAvatarCache(cfg *config.Config, userID string) ([]byte, string, error) if err != nil { continue } + if version != "" { + // look for exact match: userID.version.* + prefix := fmt.Sprintf("%s.%s", userID, version) + for _, e := range entries { + if strings.HasPrefix(e.Name(), prefix) { + p := filepath.Join(dir, e.Name()) + b, err := os.ReadFile(p) + if err != nil { + return nil, "", err + } + ext := filepath.Ext(e.Name()) + ct := mime.TypeByExtension(ext) + if ct == "" { + ct = "application/octet-stream" + } + return b, ct, nil + } + } + // not found + continue + } + // no version specified: return latest by modtime of files starting with userID. + var latest os.FileInfo + var latestName string for _, e := range entries { if strings.HasPrefix(e.Name(), userID+".") { - p := filepath.Join(dir, e.Name()) - b, err := os.ReadFile(p) + fi, err := e.Info() if err != nil { - return nil, "", err + continue } - ext := filepath.Ext(e.Name()) - ct := mime.TypeByExtension(ext) - if ct == "" { - ct = "application/octet-stream" + if latest == nil || fi.ModTime().After(latest.ModTime()) { + latest = fi + latestName = e.Name() } - return b, ct, nil } } + if latest != nil { + p := filepath.Join(dir, latestName) + b, err := os.ReadFile(p) + if err != nil { + return nil, "", err + } + ext := filepath.Ext(latestName) + ct := mime.TypeByExtension(ext) + if ct == "" { + ct = "application/octet-stream" + } + return b, ct, nil + } } return nil, "", fmt.Errorf("cache miss") } @@ -4163,9 +4204,18 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas return } - // Save avatar bytes to local cache so it can be served even if WebDAV becomes temporarily unavailable - if err := writeAvatarCache(cfg, userID.String(), ext, fileBytes); err != nil { - fmt.Printf("[WARN] failed to write avatar cache for user=%s: %v\n", userID.String(), err) + // Build public URL including version based on updated_at and include token if available + var version int64 = time.Now().Unix() + // Try to use updated_at from DB to be more accurate + var updatedAt time.Time + err = db.QueryRowContext(r.Context(), `SELECT updated_at FROM users WHERE id = $1`, userID).Scan(&updatedAt) + if err == nil { + version = updatedAt.Unix() + } + // Save avatar bytes to local cache keyed by version so it survives restarts and avoids unnecessary re-downloads + versionStr := fmt.Sprintf("%d", version) + if err := writeAvatarCache(cfg, userID.String(), versionStr, ext, fileBytes); err != nil { + fmt.Printf("[WARN] failed to write avatar cache for user=%s version=%s: %v\n", userID.String(), versionStr, err) } // Audit log @@ -4179,19 +4229,10 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas }, }) - // Build public URL including version based on updated_at and include token if available - var version int64 = time.Now().Unix() - // Try to use updated_at from DB to be more accurate - var updatedAt time.Time - err = db.QueryRowContext(r.Context(), `SELECT updated_at FROM users WHERE id = $1`, userID).Scan(&updatedAt) - if err == nil { - version = updatedAt.Unix() - } publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", version) if token, ok := middleware.GetToken(r.Context()); ok && token != "" { publicURL = fmt.Sprintf("%s&token=%s", publicURL, token) } - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Avatar uploaded successfully", @@ -4247,6 +4288,19 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D return } + v := r.URL.Query().Get("v") + // If client supplied a version and we have a matching cache, serve it immediately to avoid hitting WebDAV + if 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 (v=%s) without contacting WebDAV\n", userID.String(), v) + return + } + } + // Download from WebDAV client := storage.NewWebDAVClient(cfg) if client == nil { @@ -4263,12 +4317,13 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D 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) { - if data, ct, cerr := readAvatarCache(cfg, userID.String()); cerr == nil { + 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\n", userID.String()) + fmt.Printf("[INFO] Served avatar for user=%s from cache due to WebDAV timeout (v=%s)\n", userID.String(), v) return } @@ -4299,12 +4354,13 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D } } - // Save to cache asynchronously (best-effort) - go func() { - if err := writeAvatarCache(cfg, userID.String(), filepath.Ext(remotePath), bodyBytes); err != nil { - fmt.Printf("[WARN] failed to write avatar cache for user=%s: %v\n", userID.String(), err) + // Save to cache asynchronously (best-effort). Use provided v query param if present. + v = r.URL.Query().Get("v") + go func(v string) { + if err := writeAvatarCache(cfg, userID.String(), v, filepath.Ext(remotePath), bodyBytes); err != nil { + fmt.Printf("[WARN] failed to write avatar cache for user=%s v=%s: %v\n", userID.String(), v, err) } - }() + }(v) // Copy headers but ensure sensible caching w.Header().Set("Content-Type", contentType)