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

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

View File

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