Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 51 additions & 34 deletions api/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
Expand All @@ -28,13 +29,18 @@ type PreferencesManager interface {
List(ctx context.Context, mailingListName string) ([]domain.Topic, error)
}

type RedirectPages struct {
SubscribeSuccess string
SubscribeError string
ConfirmSuccess string
ConfirmError string
UnsubscribeSuccess string
UnsubscribeError string
type MessageStrings struct {
SubscribeSuccess string
SubscribeErrorInvalidInput string
SubscribeErrorAlreadySubscribed string
SubscribeError string
ConfirmSuccess string
ConfirmErrorInvalidToken string
UnsubscribeSuccess string
UnsubscribeErrorInvalidToken string
NewsletterNotFound string
PreferencesErrorInvalidToken string
PreferencesError string
}

type PublicHandler struct {
Expand All @@ -43,12 +49,12 @@ type PublicHandler struct {
preferences PreferencesManager
users domain.UserRepository
renderer domain.Renderer
redirects RedirectPages
messages MessageStrings
logger *slog.Logger
}

func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, preferences PreferencesManager, users domain.UserRepository, renderer domain.Renderer, redirects RedirectPages, logger *slog.Logger) *PublicHandler {
return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, preferences: preferences, users: users, renderer: renderer, redirects: redirects, logger: logger}
func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, preferences PreferencesManager, users domain.UserRepository, renderer domain.Renderer, messages MessageStrings, logger *slog.Logger) *PublicHandler {
return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, preferences: preferences, users: users, renderer: renderer, messages: messages, logger: logger}
}

func (h *PublicHandler) Routes() *http.ServeMux {
Expand All @@ -67,21 +73,25 @@ func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request)

name, email, err := parseSubscribeBody(r)
if err != nil {
redirectOrError(w, r, h.redirects.SubscribeError, http.StatusBadRequest, err.Error())
h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.SubscribeErrorInvalidInput, map[string]any{"listName": listName})
return
}

if _, err := h.subscriptions.Subscribe(r.Context(), listName, name, email, nil); err != nil {
if errors.Is(err, domain.ErrUserAlreadyConfirmed) {
h.writeMessagePage(w, r, http.StatusConflict, h.messages.SubscribeErrorAlreadySubscribed, map[string]any{"listName": listName, "email": email})
return
}
h.logger.ErrorContext(r.Context(), "subscribe failed",
slog.String("list", listName),
slog.String("email", email),
slog.Any("error", err),
)
redirectOrError(w, r, h.redirects.SubscribeError, http.StatusInternalServerError, "subscription failed")
h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.SubscribeError, map[string]any{"listName": listName})
return
}

redirectOrJSON(w, r, h.redirects.SubscribeSuccess, http.StatusAccepted, map[string]string{"message": "check your email for a confirmation link"})
h.writeMessagePage(w, r, http.StatusAccepted, h.messages.SubscribeSuccess, map[string]any{"listName": listName})
}

func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) {
Expand All @@ -92,11 +102,11 @@ func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) {
slog.String("token", token),
slog.Any("error", err),
)
redirectOrError(w, r, h.redirects.ConfirmError, http.StatusBadRequest, "invalid or expired confirmation token")
h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.ConfirmErrorInvalidToken, nil)
return
}

redirectOrJSON(w, r, h.redirects.ConfirmSuccess, http.StatusOK, map[string]string{"message": "your subscription has been confirmed"})
h.writeMessagePage(w, r, http.StatusOK, h.messages.ConfirmSuccess, nil)
}

func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
Expand All @@ -107,11 +117,11 @@ func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request
slog.String("token", token),
slog.Any("error", err),
)
redirectOrError(w, r, h.redirects.UnsubscribeError, http.StatusBadRequest, "invalid or expired unsubscribe token")
h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.UnsubscribeErrorInvalidToken, nil)
return
}

redirectOrJSON(w, r, h.redirects.UnsubscribeSuccess, http.StatusOK, map[string]string{"message": "you have been unsubscribed"})
h.writeMessagePage(w, r, http.StatusOK, h.messages.UnsubscribeSuccess, nil)
}

func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.Request) {
Expand All @@ -129,7 +139,7 @@ func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.R
slog.Uint64("id", id),
slog.Any("error", err),
)
writeError(w, http.StatusNotFound, "newsletter not found")
h.writeMessagePage(w, r, http.StatusNotFound, h.messages.NewsletterNotFound, nil)
return
}

Expand Down Expand Up @@ -161,21 +171,21 @@ func (h *PublicHandler) handlePreferencesPage(w http.ResponseWriter, r *http.Req
user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token)
if err != nil {
h.logger.ErrorContext(r.Context(), "preferences: user lookup failed", slog.Any("error", err))
writeError(w, http.StatusNotFound, "invalid token")
h.writeMessagePage(w, r, http.StatusNotFound, h.messages.PreferencesErrorInvalidToken, nil)
return
}

allTopics, err := h.preferences.List(r.Context(), user.MailingListName)
if err != nil {
h.logger.ErrorContext(r.Context(), "preferences: list topics failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load topics")
h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil)
return
}

userTopics, err := h.preferences.GetUserTopics(r.Context(), user.MailingListName, user.ID)
if err != nil {
h.logger.ErrorContext(r.Context(), "preferences: get user topics failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load preferences")
h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil)
return
}

Expand All @@ -198,7 +208,7 @@ func (h *PublicHandler) handlePreferencesPage(w http.ResponseWriter, r *http.Req
rendered, err := h.renderer.RenderHTML(preferencesTemplate, data)
if err != nil {
h.logger.ErrorContext(r.Context(), "preferences: render failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to render preferences")
h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil)
return
}

Expand All @@ -213,12 +223,12 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req
user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token)
if err != nil {
h.logger.ErrorContext(r.Context(), "save preferences: user lookup failed", slog.Any("error", err))
writeError(w, http.StatusNotFound, "invalid token")
h.writeMessagePage(w, r, http.StatusNotFound, h.messages.PreferencesErrorInvalidToken, nil)
return
}

if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid form data")
h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.PreferencesError, nil)
return
}

Expand All @@ -234,7 +244,7 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req

if err := h.preferences.SetUserTopics(r.Context(), user.MailingListName, user.ID, topicIDs); err != nil {
h.logger.ErrorContext(r.Context(), "save preferences failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to save preferences")
h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil)
return
}

Expand All @@ -243,20 +253,27 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req
http.Redirect(w, r, fmt.Sprintf("/preferences/%s/%s?saved=1", listName, token), http.StatusSeeOther)
}

func redirectOrJSON(w http.ResponseWriter, r *http.Request, redirectURL string, status int, v any) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
func (h *PublicHandler) writeMessagePage(w http.ResponseWriter, r *http.Request, status int, markdown string, extra map[string]any) {
if h.renderer == nil || markdown == "" {
writeError(w, status, http.StatusText(status))
return
}
writeJSON(w, status, v)
}

func redirectOrError(w http.ResponseWriter, r *http.Request, redirectURL string, status int, msg string) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
data := map[string]any{"isMessage": true}
for k, v := range extra {
data[k] = v
}

_, body, err := h.renderer.Render(&markdown, data)
if err != nil {
h.logger.ErrorContext(r.Context(), "rendering message page failed", slog.Any("error", err))
writeError(w, status, http.StatusText(status))
return
}
writeError(w, status, msg)

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprint(w, body)
}

func parseSubscribeBody(r *http.Request) (name, email string, err error) {
Expand Down
Loading
Loading