diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 224038f..ea3b0d2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -101,8 +101,9 @@ func (h *Handler) handleAdminResetPassword(w http.ResponseWriter, r *http.Reques return } - // Force password reset on next login by setting the flag back. - h.deps.Users.SetMustResetPassword(id, true) + if err := h.deps.Users.SetMustResetPassword(id, true); err != nil { + slog.Error("set must-reset-password error", "error", err) + } // Invalidate all sessions for this user. if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil { @@ -252,7 +253,7 @@ func (h *Handler) handleAdminSignupRequestAction(w http.ResponseWriter, r *http. case "approve": if err := h.deps.SignupRequests.Approve(id, admin.ID); err != nil { slog.Error("approve signup request error", "error", err) - h.adminRequestsFlash(w, r, "Noe gikk galt: "+err.Error(), "error") + h.adminRequestsFlash(w, r, "Noe gikk galt ved godkjenning.", "error") return } h.adminRequestsFlash(w, r, "Forespørsel godkjent. Brukeren må endre passord ved første innlogging.", "success") diff --git a/internal/handler/api/api.go b/internal/handler/api/api.go index 0937641..2c3e685 100644 --- a/internal/handler/api/api.go +++ b/internal/handler/api/api.go @@ -6,6 +6,7 @@ package api import ( "encoding/json" "errors" + "io" "log/slog" "net/http" "strconv" @@ -236,9 +237,15 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) { req.Privacy = fave.Privacy } - h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy) + if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy); err != nil { + slog.Error("api: update fave error", "error", err) + jsonError(w, "Internal error", http.StatusInternalServerError) + return + } if req.Tags != nil { - h.deps.Tags.SetFaveTags(id, req.Tags) + if err := h.deps.Tags.SetFaveTags(id, req.Tags); err != nil { + slog.Error("api: set tags error", "error", err) + } } updated, _ := h.deps.Faves.GetByID(id) @@ -269,7 +276,11 @@ func (h *Handler) handleDeleteFave(w http.ResponseWriter, r *http.Request) { return } - h.deps.Faves.Delete(id) + if err := h.deps.Faves.Delete(id); err != nil { + slog.Error("api: delete fave error", "error", err) + jsonError(w, "Internal error", http.StatusInternalServerError) + return + } w.WriteHeader(http.StatusNoContent) } @@ -349,7 +360,7 @@ func (h *Handler) handleGetUserFaves(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) - faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) + faves, _, err := h.deps.Faves.ListByUser(user.ID, 100000, 0) if err != nil { jsonError(w, "Internal error", http.StatusInternalServerError) return @@ -361,6 +372,9 @@ func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) + // Limit request body to prevent memory exhaustion. + r.Body = io.NopCloser(io.LimitReader(r.Body, h.deps.Config.MaxUploadSize)) + var faves []struct { Description string `json:"description"` URL string `json:"url"` diff --git a/internal/handler/feed.go b/internal/handler/feed.go index 58b17c8..d5d945c 100644 --- a/internal/handler/feed.go +++ b/internal/handler/feed.go @@ -4,6 +4,7 @@ package handler import ( "errors" + "html" "log/slog" "net/http" "strconv" @@ -42,7 +43,7 @@ func (h *Handler) handleFeedGlobal(w http.ResponseWriter, r *http.Request) { Title: siteName + " — Siste favoritter", Link: &feeds.Link{Href: baseURL}, Description: "Siste offentlige favoritter", - Updated: time.Now(), + Updated: feedUpdatedTime(faves), } feed.Items = favesToFeedItems(faves, baseURL) @@ -84,7 +85,7 @@ func (h *Handler) handleFeedUser(w http.ResponseWriter, r *http.Request) { feed := &feeds.Feed{ Title: user.DisplayNameOrUsername() + " sine favoritter", Link: &feeds.Link{Href: baseURL + "/u/" + user.Username}, - Updated: time.Now(), + Updated: feedUpdatedTime(faves), } feed.Items = favesToFeedItems(faves, baseURL) @@ -110,7 +111,7 @@ func (h *Handler) handleFeedTag(w http.ResponseWriter, r *http.Request) { feed := &feeds.Feed{ Title: "Favoritter med merkelapp: " + tagName, Link: &feeds.Link{Href: baseURL + "/tags/" + tagName}, - Updated: time.Now(), + Updated: feedUpdatedTime(faves), } feed.Items = favesToFeedItems(faves, baseURL) @@ -129,7 +130,8 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item { } if f.URL != "" { - item.Content = `

` + f.URL + `

` + escaped := html.EscapeString(f.URL) + item.Content = `

` + escaped + `

` } if f.ImagePath != "" { @@ -155,6 +157,15 @@ func (h *Handler) writeAtom(w http.ResponseWriter, feed *feeds.Feed) { w.Write([]byte(atom)) } +// feedUpdatedTime returns the most recent UpdatedAt from the faves, +// falling back to now if the list is empty. +func feedUpdatedTime(faves []*model.Fave) time.Time { + if len(faves) > 0 { + return faves[0].UpdatedAt + } + return time.Now() +} + func itoa(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/internal/handler/import_export.go b/internal/handler/import_export.go index 3f7abb9..9978570 100644 --- a/internal/handler/import_export.go +++ b/internal/handler/import_export.go @@ -15,6 +15,8 @@ import ( "kode.naiv.no/olemd/favoritter/internal/render" ) +const maxExportFaves = 100000 + // ExportFave is the JSON representation for export/import. type ExportFave struct { Description string `json:"description"` @@ -35,7 +37,7 @@ func (h *Handler) handleExportPage(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) - faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) + faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0) if err != nil { slog.Error("export: list faves error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) @@ -72,7 +74,7 @@ func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) - faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) + faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0) if err != nil { slog.Error("export: list faves error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) diff --git a/internal/store/signup_request.go b/internal/store/signup_request.go index 58e8de4..19d032d 100644 --- a/internal/store/signup_request.go +++ b/internal/store/signup_request.go @@ -102,18 +102,28 @@ func (s *SignupRequestStore) ListPending() ([]*model.SignupRequest, error) { // Approve marks a request as approved and creates the user account. // The new user will have must_reset_password=1. func (s *SignupRequestStore) Approve(id int64, adminID int64) error { - sr, err := s.GetByID(id) + tx, err := s.db.Begin() if err != nil { - return err + return fmt.Errorf("begin tx: %w", err) } - if sr.Status != "pending" { - return fmt.Errorf("request is not pending (status: %s)", sr.Status) + defer tx.Rollback() + + // Fetch and verify the request is still pending, within the transaction. + var username, passwordHash, status string + err = tx.QueryRow( + `SELECT username, password_hash, status FROM signup_requests WHERE id = ?`, id, + ).Scan(&username, &passwordHash, &status) + if err != nil { + return ErrSignupRequestNotFound + } + if status != "pending" { + return fmt.Errorf("request is not pending (status: %s)", status) } // Create the user with the already-hashed password. - _, err = s.db.Exec( + _, err = tx.Exec( `INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`, - sr.Username, sr.PasswordHash, + username, passwordHash, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { @@ -123,14 +133,18 @@ func (s *SignupRequestStore) Approve(id int64, adminID int64) error { } // Mark the request as approved. - _, err = s.db.Exec( + _, err = tx.Exec( `UPDATE signup_requests SET status = 'approved', reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), reviewed_by = ? WHERE id = ?`, adminID, id, ) - return err + if err != nil { + return fmt.Errorf("update request status: %w", err) + } + + return tx.Commit() } // Reject marks a request as rejected.