Add avatar caching functionality and update config for cache directory
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user