diff --git a/cmd/favoritter/main.go b/cmd/favoritter/main.go index c21e9cb..5ffe4fe 100644 --- a/cmd/favoritter/main.go +++ b/cmd/favoritter/main.go @@ -115,6 +115,7 @@ func main() { middleware.RequestLogger, middleware.SessionLoader(sessions, users), middleware.CSRFProtection(cfg), + middleware.MustResetPasswordGuard(cfg.BasePath), ) srv := &http.Server{ diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 3af28b2..056ecc8 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -241,7 +241,28 @@ func (h *Handler) handleResetPasswordPost(w http.ResponseWriter, r *http.Request } func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) { - h.deps.Renderer.Page(w, r, "home", render.PageData{}) + user := middleware.UserFromContext(r.Context()) + + var data map[string]any + if user != nil { + page := queryInt(r, "page", 1) + offset := (page - 1) * defaultPageSize + faves, total, err := h.deps.Faves.ListPublic(defaultPageSize, offset) + if err != nil { + slog.Error("list public faves error", "error", err) + } else { + h.deps.Faves.LoadTags(faves) + totalPages := (total + defaultPageSize - 1) / defaultPageSize + data = map[string]any{ + "Faves": faves, + "Page": page, + "TotalPages": totalPages, + "Total": total, + } + } + } + + h.deps.Renderer.Page(w, r, "home", render.PageData{Data: data}) } func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d74680d..7514231 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -103,5 +103,14 @@ func (h *Handler) Routes() *http.ServeMux { mux.HandleFunc("GET /tags/search", h.handleTagSearch) mux.HandleFunc("GET /tags/{name}", h.handleTagBrowse) + // Profiles. + mux.HandleFunc("GET /u/{username}", h.handlePublicProfile) + + // User settings (authenticated). + mux.Handle("GET /settings", requireLogin(http.HandlerFunc(h.handleSettingsGet))) + mux.Handle("POST /settings", requireLogin(http.HandlerFunc(h.handleSettingsPost))) + mux.Handle("POST /settings/avatar", requireLogin(http.HandlerFunc(h.handleAvatarPost))) + mux.Handle("POST /settings/password", requireLogin(http.HandlerFunc(h.handleSettingsPasswordPost))) + return mux } diff --git a/internal/handler/profile.go b/internal/handler/profile.go new file mode 100644 index 0000000..7c520f3 --- /dev/null +++ b/internal/handler/profile.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handler + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "kode.naiv.no/olemd/favoritter/internal/image" + "kode.naiv.no/olemd/favoritter/internal/middleware" + "kode.naiv.no/olemd/favoritter/internal/model" + "kode.naiv.no/olemd/favoritter/internal/render" + "kode.naiv.no/olemd/favoritter/internal/store" +) + +// handlePublicProfile shows a user's public profile and their public faves. +func (h *Handler) handlePublicProfile(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + + profileUser, err := h.deps.Users.GetByUsername(username) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + http.NotFound(w, r) + return + } + slog.Error("get user error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if profileUser.Disabled { + http.NotFound(w, r) + return + } + + // Check if the viewing user is the profile owner. + viewer := middleware.UserFromContext(r.Context()) + isOwner := viewer != nil && viewer.ID == profileUser.ID + + page := queryInt(r, "page", 1) + offset := (page - 1) * defaultPageSize + + var faves []*model.Fave + var total int + + if isOwner { + faves, total, err = h.deps.Faves.ListByUser(profileUser.ID, defaultPageSize, offset) + } else { + faves, total, err = h.deps.Faves.ListPublicByUser(profileUser.ID, defaultPageSize, offset) + } + if err != nil { + slog.Error("list faves error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("load tags error", "error", err) + } + + totalPages := (total + defaultPageSize - 1) / defaultPageSize + + h.deps.Renderer.Page(w, r, "profile", render.PageData{ + Title: profileUser.DisplayNameOrUsername() + " sine favoritter", + Data: map[string]any{ + "ProfileUser": profileUser, + "IsOwner": isOwner, + "IsLimited": profileUser.ProfileVisibility == "limited" && !isOwner, + "Faves": faves, + "Page": page, + "TotalPages": totalPages, + "Total": total, + }, + }) +} + +// handleSettingsGet shows the user settings page. +func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + h.deps.Renderer.Page(w, r, "settings", render.PageData{ + Title: "Innstillinger", + Data: map[string]any{ + "SettingsUser": user, + }, + }) +} + +// handleSettingsPost processes the settings form. +func (h *Handler) handleSettingsPost(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + if err := r.ParseForm(); err != nil { + h.flash(w, r, "settings", "Ugyldig forespørsel.", "error", settingsData(user)) + return + } + + displayName := strings.TrimSpace(r.FormValue("display_name")) + bio := strings.TrimSpace(r.FormValue("bio")) + profileVisibility := r.FormValue("profile_visibility") + defaultFavePrivacy := r.FormValue("default_fave_privacy") + + if profileVisibility != "public" && profileVisibility != "limited" { + profileVisibility = user.ProfileVisibility + } + if defaultFavePrivacy != "public" && defaultFavePrivacy != "private" { + defaultFavePrivacy = user.DefaultFavePrivacy + } + + if err := h.deps.Users.UpdateProfile(user.ID, displayName, bio, profileVisibility, defaultFavePrivacy); err != nil { + slog.Error("update profile error", "error", err) + h.flash(w, r, "settings", "Noe gikk galt. Prøv igjen.", "error", settingsData(user)) + return + } + + h.flash(w, r, "settings", "Innstillingene er lagret.", "success", settingsData(user)) +} + +// handleSettingsPasswordPost handles password change from the settings page. +func (h *Handler) handleSettingsPasswordPost(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + if err := r.ParseForm(); err != nil { + h.flash(w, r, "settings", "Ugyldig forespørsel.", "error", settingsData(user)) + return + } + + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + // Verify current password. + if _, err := h.deps.Users.Authenticate(user.Username, currentPassword); err != nil { + h.flash(w, r, "settings", "Nåværende passord er feil.", "error", settingsData(user)) + return + } + + if len(newPassword) < 8 { + h.flash(w, r, "settings", "Nytt passord må være minst 8 tegn.", "error", settingsData(user)) + return + } + + if newPassword != confirmPassword { + h.flash(w, r, "settings", "Passordene er ikke like.", "error", settingsData(user)) + return + } + + if err := h.deps.Users.UpdatePassword(user.ID, newPassword); err != nil { + slog.Error("update password error", "error", err) + h.flash(w, r, "settings", "Noe gikk galt. Prøv igjen.", "error", settingsData(user)) + return + } + + // Invalidate other sessions, keep current one. + if delErr := h.deps.Sessions.DeleteAllForUser(user.ID); delErr != nil { + slog.Error("session cleanup error", "error", delErr) + } + token, err := h.deps.Sessions.Create(user.ID) + if err != nil { + slog.Error("session create error", "error", err) + http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther) + return + } + h.setSessionCookie(w, r, token) + + h.flash(w, r, "settings", "Passordet er endret.", "success", settingsData(user)) +} + +// handleAvatarPost handles avatar upload. +func (h *Handler) handleAvatarPost(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil { + h.flash(w, r, "settings", "Filen er for stor.", "error", settingsData(user)) + return + } + + file, header, err := r.FormFile("avatar") + if err != nil { + h.flash(w, r, "settings", "Velg et bilde å laste opp.", "error", settingsData(user)) + return + } + defer file.Close() + + result, err := image.Process(file, header, h.deps.Config.UploadDir) + if err != nil { + slog.Error("avatar process error", "error", err) + h.flash(w, r, "settings", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde.", "error", settingsData(user)) + return + } + + // Delete old avatar if there was one. + if user.AvatarPath != "" { + if delErr := image.Delete(h.deps.Config.UploadDir, user.AvatarPath); delErr != nil { + slog.Error("avatar delete error", "error", delErr) + } + } + + if err := h.deps.Users.UpdateAvatar(user.ID, result.Filename); err != nil { + slog.Error("update avatar error", "error", err) + h.flash(w, r, "settings", "Noe gikk galt. Prøv igjen.", "error", settingsData(user)) + return + } + + h.flash(w, r, "settings", "Profilbildet er oppdatert.", "success", settingsData(user)) +} + +func settingsData(user *model.User) map[string]any { + return map[string]any{"SettingsUser": user} +} diff --git a/internal/middleware/resetguard.go b/internal/middleware/resetguard.go new file mode 100644 index 0000000..f21471f --- /dev/null +++ b/internal/middleware/resetguard.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package middleware + +import ( + "net/http" + "strings" +) + +// MustResetPasswordGuard redirects users who must reset their password +// to the reset page. Allows through: static assets, health, logout, +// and the reset-password page itself. +func MustResetPasswordGuard(basePath string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user != nil && user.MustResetPassword { + path := r.URL.Path + // Allow these paths through without redirect. + if path == "/reset-password" || + path == "/logout" || + path == "/health" || + strings.HasPrefix(path, "/static/") { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, basePath+"/reset-password", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index fa55f47..763e273 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -88,6 +88,23 @@ vertical-align: middle; } +/* Profile header */ +.profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1rem; +} + +/* Avatar */ +.avatar-large { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + /* Tag chips */ .tag-chip { display: inline-block; diff --git a/web/templates/pages/home.html b/web/templates/pages/home.html index e01b798..937970a 100644 --- a/web/templates/pages/home.html +++ b/web/templates/pages/home.html @@ -1,10 +1,59 @@ {{define "content"}} -
-

Velkommen til {{.SiteName}}

-

Del dine favoritter med verden — eller behold dem for deg selv.

-
+{{if .User}} +
+

Siste offentlige favoritter

+

Se hva folk deler

+
-{{if not .User}} + {{with .Data}} + {{if .Faves}} +
+ {{range .Faves}} +
+ {{if .ImagePath}} + Bilde for: {{.Description}} + {{end}} +
+ + {{.Description}} + + av {{.DisplayName}} +
+ {{if .Tags}} + + {{end}} +
+ {{end}} +
+ + {{if gt .TotalPages 1}} + + {{end}} + {{else}} +

Ingen offentlige favoritter ennå. Legg til din første!

+ {{end}} + {{end}} +{{else}} +
+

Velkommen til {{.SiteName}}

+

Del dine favoritter med verden — eller behold dem for deg selv.

+
Logg inn Registrer deg diff --git a/web/templates/pages/profile.html b/web/templates/pages/profile.html new file mode 100644 index 0000000..92b2492 --- /dev/null +++ b/web/templates/pages/profile.html @@ -0,0 +1,106 @@ +{{define "head"}} +{{with .Data}}{{with .ProfileUser}} + {{if eq .ProfileVisibility "public"}} + + + {{if $.ExternalURL}} + + {{if .AvatarPath}} + + {{end}} + {{end}} + + {{end}} +{{end}}{{end}} +{{end}} + +{{define "content"}} +{{with .Data}} + {{with .ProfileUser}} +
+ {{if .AvatarPath}} + Profilbilde for {{.DisplayNameOrUsername}} + {{end}} +
+

{{.DisplayNameOrUsername}}

+ {{if and (ne .DisplayName "") (ne .DisplayName .Username)}} +

@{{.Username}}

+ {{end}} +
+
+ + {{if not $.IsLimited}} + {{if .Bio}} +

{{.Bio}}

+ {{end}} + +

Medlem siden {{.CreatedAt.Format "02.01.2006"}}

+ + {{if $.IsOwner}} +

+ Rediger profil + + Ny favoritt +

+ {{end}} + +

+ {{if $.IsOwner}}Favoritter{{else}}Offentlige favoritter{{end}} + ({{$.Total}}) +

+ + {{if $.Faves}} +
+ {{range $.Faves}} +
+ {{if .ImagePath}} + Bilde for: {{.Description}} + {{end}} +
+ + {{.Description}} + + {{if eq .Privacy "private"}} + Privat + {{end}} +
+ {{if .Tags}} + + {{end}} +
+ {{end}} +
+ + {{if gt $.TotalPages 1}} + + {{end}} + {{else}} + {{if $.IsOwner}} +

Du har ingen favoritter ennå. Legg til din første!

+ {{else}} +

Ingen offentlige favoritter ennå.

+ {{end}} + {{end}} + {{else}} +

Denne profilen har begrenset synlighet.

+ {{end}} + {{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/settings.html b/web/templates/pages/settings.html new file mode 100644 index 0000000..ff40501 --- /dev/null +++ b/web/templates/pages/settings.html @@ -0,0 +1,95 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +{{with .Data}}{{with .SettingsUser}} +

Innstillinger

+ +
+

Profil

+
+ + + +
+ Profilsynlighet + + +
+
+ Standard synlighet for nye favoritter + + +
+ +
+
+ +
+

Profilbilde

+ {{if .AvatarPath}} + Nåværende profilbilde + {{end}} +
+ + + +
+
+ +
+

Endre passord

+
+ + + + + +
+
+{{end}}{{end}} +{{end}}