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>(
|
? 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user