From a2884a98915d9c6a04c4533a9caf3ef2a99ac5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Thu, 29 Jan 2026 23:00:59 +0100 Subject: [PATCH] Add avatar caching functionality and update config for cache directory --- go_cloud/internal/config/config.go | 4 +- go_cloud/internal/http/routes.go | 91 +++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/go_cloud/internal/config/config.go b/go_cloud/internal/config/config.go index e8f24a0..3b361ea 100644 --- a/go_cloud/internal/config/config.go +++ b/go_cloud/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { NextcloudPass string NextcloudBase string AllowedOrigins string + AvatarCacheDir string } func Load() *Config { @@ -34,8 +35,9 @@ func Load() *Config { 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"), } - log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase) + log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir) return cfg } diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 0c3fdfb..40c8c9c 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -10,9 +10,11 @@ import ( "fmt" "io" "log" + "mime" "mime/multipart" "net/http" "net/url" + "os" "path" "path/filepath" "strings" @@ -52,6 +54,48 @@ func sanitizePath(inputPath string) (string, error) { return cleaned, nil } +// Avatar cache helpers +func avatarCachePath(cfg *config.Config, userID string, ext string) string { + dir := cfg.AvatarCacheDir + if dir == "" { + dir = "/var/cache/b0esche/avatars" + } + os.MkdirAll(dir, 0755) + return filepath.Join(dir, fmt.Sprintf("%s%s", userID, ext)) +} + +func writeAvatarCache(cfg *config.Config, userID string, ext string, data []byte) error { + p := avatarCachePath(cfg, userID, ext) + return os.WriteFile(p, data, 0644) +} + +func readAvatarCache(cfg *config.Config, userID string) ([]byte, string, error) { + dir := cfg.AvatarCacheDir + if dir == "" { + dir = "/var/cache/b0esche/avatars" + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, "", err + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), userID+".") { + 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 + } + } + return nil, "", fmt.Errorf("cache miss") +} + // getUserWebDAVClient gets or creates a user's Nextcloud account and returns a WebDAV client for them func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID, nextcloudBaseURL, adminUser, adminPass string) (*storage.WebDAVClient, error) { var user struct { @@ -4100,6 +4144,11 @@ 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) + } + // Audit log auditLogger.Log(r.Context(), audit.Entry{ UserID: &userID, @@ -4193,8 +4242,17 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D resp, err := client.Download(ctx, remotePath, "") if err != nil { - // If download timed out or gateway returned 504, treat as "no avatar" so frontend shows default icon + // 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 { + 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()) + return + } + fmt.Printf("[ERROR] Avatar download timeout for remotePath=%s: %v\n", remotePath, err) w.WriteHeader(http.StatusNotFound) return @@ -4206,15 +4264,34 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D } defer resp.Body.Close() - // Copy headers but ensure sensible caching - for k, v := range resp.Header { - w.Header()[k] = v + // Read body (so we can cache it) and determine content-type + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + errors.LogError(r, err, "Failed to read avatar body") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return } - // Opt into short caching; client-side cache is also versioned via v= - w.Header().Set("Cache-Control", "public, max-age=300") + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = mime.TypeByExtension(filepath.Ext(remotePath)) + if contentType == "" { + contentType = "application/octet-stream" + } + } + + // 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) + } + }() + + // Copy headers but ensure sensible caching + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=300") w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) + w.Write(bodyBytes) } // deleteUserAccountHandler handles user account deletion