Add avatar caching functionality and update config for cache directory

This commit is contained in:
Leon Bösche
2026-01-29 23:00:59 +01:00
parent 00a585e2c1
commit a2884a9891
2 changed files with 87 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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