Add avatar caching functionality and update config for cache directory
This commit is contained in:
@@ -18,6 +18,7 @@ type Config struct {
|
|||||||
NextcloudPass string
|
NextcloudPass string
|
||||||
NextcloudBase string
|
NextcloudBase string
|
||||||
AllowedOrigins string
|
AllowedOrigins string
|
||||||
|
AvatarCacheDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -34,8 +35,9 @@ func Load() *Config {
|
|||||||
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||||
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
|
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
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -52,6 +54,48 @@ func sanitizePath(inputPath string) (string, error) {
|
|||||||
return cleaned, nil
|
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
|
// 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) {
|
func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID, nextcloudBaseURL, adminUser, adminPass string) (*storage.WebDAVClient, error) {
|
||||||
var user struct {
|
var user struct {
|
||||||
@@ -4100,6 +4144,11 @@ 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
|
||||||
|
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
|
// Audit log
|
||||||
auditLogger.Log(r.Context(), audit.Entry{
|
auditLogger.Log(r.Context(), audit.Entry{
|
||||||
UserID: &userID,
|
UserID: &userID,
|
||||||
@@ -4193,8 +4242,17 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
|||||||
|
|
||||||
resp, err := client.Download(ctx, remotePath, "")
|
resp, err := client.Download(ctx, remotePath, "")
|
||||||
if err != nil {
|
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 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)
|
fmt.Printf("[ERROR] Avatar download timeout for remotePath=%s: %v\n", remotePath, err)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -4206,15 +4264,34 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Copy headers but ensure sensible caching
|
// Read body (so we can cache it) and determine content-type
|
||||||
for k, v := range resp.Header {
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
w.Header()[k] = v
|
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)
|
w.WriteHeader(resp.StatusCode)
|
||||||
io.Copy(w, resp.Body)
|
w.Write(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteUserAccountHandler handles user account deletion
|
// deleteUserAccountHandler handles user account deletion
|
||||||
|
|||||||
Reference in New Issue
Block a user