Enhance avatar caching by adding versioning support and improving cache read/write logic

This commit is contained in:
Leon Bösche
2026-01-30 13:41:17 +01:00
parent 87bf4b8ca3
commit 1bc1dd8460
3 changed files with 129 additions and 33 deletions

View File

@@ -403,10 +403,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
? BlocBuilder<AuthBloc, AuthState>( ? BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
if (state is AuthAuthenticated && 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( return CircleAvatar(
radius: 20, radius: 20,
backgroundImage: NetworkImage(state.user!.avatarUrl!), backgroundImage: NetworkImage(url),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
); );
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath"
"go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth" "go.b0esche.cloud/backend/internal/auth"
@@ -13,9 +14,43 @@ import (
"go.b0esche.cloud/backend/pkg/jwt" "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() { func main() {
cfg := config.Load() cfg := config.Load()
// Ensure avatar cache directory is usable and persistent when possible
ensureAvatarCacheDir(cfg)
dbConn, err := database.Connect(cfg) dbConn, err := database.Connect(cfg)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err) fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)

View File

@@ -55,16 +55,22 @@ func sanitizePath(inputPath string) (string, error) {
} }
// Avatar cache helpers // 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 dir := cfg.AvatarCacheDir
if dir == "" { if dir == "" {
dir = "/var/cache/b0esche/avatars" dir = "/var/cache/b0esche/avatars"
} }
os.MkdirAll(dir, 0755) os.MkdirAll(dir, 0755)
// Filename: <userID>.<version><ext> (if version empty, use <userID><ext>)
if version != "" {
return filepath.Join(dir, fmt.Sprintf("%s.%s%s", userID, version, ext))
}
return filepath.Join(dir, fmt.Sprintf("%s%s", userID, 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 dir := cfg.AvatarCacheDir
if dir == "" { if dir == "" {
dir = "/var/cache/b0esche/avatars" dir = "/var/cache/b0esche/avatars"
@@ -78,11 +84,12 @@ func writeAvatarCache(cfg *config.Config, userID string, ext string, data []byte
} }
dir = fallback 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) 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} checkDirs := []string{cfg.AvatarCacheDir}
if checkDirs[0] == "" { if checkDirs[0] == "" {
checkDirs[0] = "/var/cache/b0esche/avatars" checkDirs[0] = "/var/cache/b0esche/avatars"
@@ -95,21 +102,55 @@ func readAvatarCache(cfg *config.Config, userID string) ([]byte, string, error)
if err != nil { if err != nil {
continue 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 { for _, e := range entries {
if strings.HasPrefix(e.Name(), userID+".") { if strings.HasPrefix(e.Name(), userID+".") {
p := filepath.Join(dir, e.Name()) fi, err := e.Info()
b, err := os.ReadFile(p)
if err != nil { if err != nil {
return nil, "", err continue
} }
ext := filepath.Ext(e.Name()) if latest == nil || fi.ModTime().After(latest.ModTime()) {
ct := mime.TypeByExtension(ext) latest = fi
if ct == "" { latestName = e.Name()
ct = "application/octet-stream"
} }
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") return nil, "", fmt.Errorf("cache miss")
} }
@@ -4163,9 +4204,18 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas
return return
} }
// Save avatar bytes to local cache so it can be served even if WebDAV becomes temporarily unavailable // Build public URL including version based on updated_at and include token if available
if err := writeAvatarCache(cfg, userID.String(), ext, fileBytes); err != nil { var version int64 = time.Now().Unix()
fmt.Printf("[WARN] failed to write avatar cache for user=%s: %v\n", userID.String(), err) // 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 // 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) publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", version)
if token, ok := middleware.GetToken(r.Context()); ok && token != "" { if token, ok := middleware.GetToken(r.Context()); ok && token != "" {
publicURL = fmt.Sprintf("%s&token=%s", publicURL, token) publicURL = fmt.Sprintf("%s&token=%s", publicURL, token)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Avatar uploaded successfully", "message": "Avatar uploaded successfully",
@@ -4247,6 +4288,19 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
return 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 // Download from WebDAV
client := storage.NewWebDAVClient(cfg) client := storage.NewWebDAVClient(cfg)
if client == nil { if client == nil {
@@ -4263,12 +4317,13 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
if err != nil { if err != nil {
// If download timed out or gateway returned 504, try to serve cached avatar // 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 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("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=300") w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write(data) 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 return
} }
@@ -4299,12 +4354,13 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
} }
} }
// Save to cache asynchronously (best-effort) // Save to cache asynchronously (best-effort). Use provided v query param if present.
go func() { v = r.URL.Query().Get("v")
if err := writeAvatarCache(cfg, userID.String(), filepath.Ext(remotePath), bodyBytes); err != nil { go func(v string) {
fmt.Printf("[WARN] failed to write avatar cache for user=%s: %v\n", userID.String(), err) 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 // Copy headers but ensure sensible caching
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)