Enhance avatar caching by adding versioning support and improving cache read/write logic
This commit is contained in:
@@ -403,10 +403,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
? BlocBuilder<AuthBloc, AuthState>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,8 +102,11 @@ 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(), userID+".") {
|
||||
if strings.HasPrefix(e.Name(), prefix) {
|
||||
p := filepath.Join(dir, e.Name())
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
@@ -110,6 +120,37 @@ func readAvatarCache(cfg *config.Config, userID string) ([]byte, string, error)
|
||||
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+".") {
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || fi.ModTime().After(latest.ModTime()) {
|
||||
latest = fi
|
||||
latestName = e.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user