feat: always persist user profile to disk (#4550)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-03-21 08:29:04 -07:00
committed by GitHub
parent 6e3e82e806
commit fc1e86fc14
4 changed files with 79 additions and 52 deletions
+3 -1
View File
@@ -39,7 +39,7 @@ export function useProfileStorage<K extends keyof Profile>(
}
}
if (config.user) {
if (config.user || config.authProvider === "none") {
watch(
storage,
(value) => {
@@ -56,6 +56,8 @@ export function useProfileStorage<K extends keyof Profile>(
return value;
}
}),
}).catch((e) => {
console.error(`Failed to sync ${key} to profile`, e);
});
},
{ deep: true },
+24 -9
View File
@@ -9,12 +9,12 @@ import (
"os"
"path/filepath"
"github.com/amir20/dozzle/internal/auth"
"github.com/rs/zerolog/log"
)
const (
profileFilename = "profile.json"
profileFilename = "profile.json"
DefaultUsername = "__default__"
)
var errMissingProfileErr = errors.New("Profile file does not exist")
@@ -48,6 +48,7 @@ type Profile struct {
var dataPath string
var mux = &sync.Mutex{}
var errInvalidUsername = errors.New("invalid username: contains path separator or traversal")
func init() {
path, err := filepath.Abs("./data")
@@ -64,10 +65,18 @@ func init() {
dataPath = path
}
func UpdateFromReader(user auth.User, reader io.Reader) error {
func safePath(username string) (string, error) {
clean := filepath.Base(username)
if clean != username || clean == "." || clean == ".." {
return "", errInvalidUsername
}
return filepath.Join(dataPath, clean), nil
}
func UpdateFromReader(username string, reader io.Reader) error {
mux.Lock()
defer mux.Unlock()
existingProfile, err := Load(user)
existingProfile, err := Load(username)
if err != nil && err != errMissingProfileErr {
log.Error().Err(err).Msg("Unable to load profile. Overwriting it.")
}
@@ -76,11 +85,14 @@ func UpdateFromReader(user auth.User, reader io.Reader) error {
return err
}
return save(user, existingProfile)
return save(username, existingProfile)
}
func save(user auth.User, profile Profile) error {
path := filepath.Join(dataPath, user.Username)
func save(username string, profile Profile) error {
path, err := safePath(username)
if err != nil {
return err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.Mkdir(path, 0755); err != nil {
return err
@@ -109,8 +121,11 @@ func save(user auth.User, profile Profile) error {
return f.Sync()
}
func Load(user auth.User) (Profile, error) {
path := filepath.Join(dataPath, user.Username)
func Load(username string) (Profile, error) {
path, err := safePath(username)
if err != nil {
return Profile{}, err
}
profilePath := filepath.Join(path, profileFilename)
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
+48 -37
View File
@@ -33,31 +33,38 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
base = h.config.Base
}
hosts := h.hostService.Hosts()
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Name < hosts[j].Name
})
user := auth.UserFromContext(req.Context())
// Handle unauthorized cases early
if user == nil {
switch h.config.Authorization.Provider {
case FORWARD_PROXY:
log.Error().Msg("Unable to find remote user. Please check your proxy configuration. Expecting headers Remote-Email, Remote-User, Remote-Name.")
log.Debug().Str("url", req.URL.String()).Msg("Dumping all headers for request")
for k, v := range req.Header {
log.Debug().Strs(k, v).Send()
}
http.Error(w, "Unauthorized user", http.StatusUnauthorized)
return
case SIMPLE:
if req.URL.Path != "login" {
log.Debug().Str("url", req.URL.String()).Msg("Redirecting to login page")
http.Redirect(w, req, path.Clean(h.config.Base+"/login")+"?redirectUrl=/"+req.URL.String(), http.StatusTemporaryRedirect)
return
}
}
}
config := map[string]interface{}{
"base": base,
}
user := auth.UserFromContext(req.Context())
// Build full config when authorized (no auth or authenticated user)
if h.config.Authorization.Provider == NONE || user != nil {
if user != nil {
config["enableShell"] = h.config.EnableShell && user.Roles.Has(auth.Shell)
config["enableActions"] = h.config.EnableActions && user.Roles.Has(auth.Actions)
config["enableDownload"] = user.Roles.Has(auth.Download)
} else {
config["enableShell"] = h.config.EnableShell
config["enableActions"] = h.config.EnableActions
config["enableDownload"] = true
}
if h.config.Authorization.Provider == FORWARD_PROXY && strings.TrimSpace(h.config.Authorization.LogoutUrl) != "" {
config["logoutUrl"] = strings.TrimSpace(h.config.Authorization.LogoutUrl)
}
hosts := h.hostService.Hosts()
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Name < hosts[j].Name
})
config["authProvider"] = h.config.Authorization.Provider
config["version"] = h.config.Version
@@ -66,27 +73,31 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
config["hosts"] = hosts
config["disableAvatars"] = h.config.DisableAvatars
config["releaseCheckMode"] = h.config.ReleaseCheckMode
config["enableShell"] = h.config.EnableShell
config["enableActions"] = h.config.EnableActions
config["enableDownload"] = true
if user != nil {
config["enableShell"] = h.config.EnableShell && user.Roles.Has(auth.Shell)
config["enableActions"] = h.config.EnableActions && user.Roles.Has(auth.Actions)
config["enableDownload"] = user.Roles.Has(auth.Download)
config["user"] = user
}
if h.config.Authorization.Provider == FORWARD_PROXY && strings.TrimSpace(h.config.Authorization.LogoutUrl) != "" {
config["logoutUrl"] = strings.TrimSpace(h.config.Authorization.LogoutUrl)
}
}
profileUsername := profile.DefaultUsername
if user != nil {
if profile, err := profile.Load(*user); err == nil {
config["profile"] = profile
} else {
config["profile"] = struct{}{}
}
config["user"] = user
} else if h.config.Authorization.Provider == FORWARD_PROXY {
log.Error().Msg("Unable to find remote user. Please check your proxy configuration. Expecting headers Remote-Email, Remote-User, Remote-Name.")
log.Debug().Str("url", req.URL.String()).Msg("Dumping all headers for request")
for k, v := range req.Header {
log.Debug().Strs(k, v).Send()
}
http.Error(w, "Unauthorized user", http.StatusUnauthorized)
return
} else if h.config.Authorization.Provider == SIMPLE && req.URL.Path != "login" {
log.Debug().Str("url", req.URL.String()).Msg("Redirecting to login page")
http.Redirect(w, req, path.Clean(h.config.Base+"/login")+"?redirectUrl=/"+req.URL.String(), http.StatusTemporaryRedirect)
return
profileUsername = user.Username
}
if loadedProfile, err := profile.Load(profileUsername); err == nil {
config["profile"] = loadedProfile
} else {
config["profile"] = struct{}{}
}
data := map[string]interface{}{
+4 -5
View File
@@ -10,13 +10,12 @@ import (
)
func (h *handler) updateProfile(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
http.Error(w, "Unable to find user", http.StatusInternalServerError)
return
username := profile.DefaultUsername
if user := auth.UserFromContext(r.Context()); user != nil {
username = user.Username
}
if err := profile.UpdateFromReader(*user, r.Body); err != nil {
if err := profile.UpdateFromReader(username, r.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Error().Err(err).Msg("Failed to update profile")
return