From f721e563718ee0c0c2718ea17f5ef920516d9718 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Thu, 2 Oct 2025 06:35:27 +0200 Subject: [PATCH] refactor: Use functional errors Use well-defined server errors instead of hardcoded messages that could be slightly different and spread accross the code base. Signed-off-by: Julien Riou --- src/server/errors.go | 24 +++++++ src/server/handlers_api.go | 63 +++++++++++------- src/server/handlers_web.go | 130 +++++++++++++++++++++---------------- 3 files changed, 137 insertions(+), 80 deletions(-) create mode 100644 src/server/errors.go diff --git a/src/server/errors.go b/src/server/errors.go new file mode 100644 index 0000000..a863d63 --- /dev/null +++ b/src/server/errors.go @@ -0,0 +1,24 @@ +package server + +import "errors" + +var ( + ErrCouldNotFindNote = errors.New("could not find note") + ErrNoteDoesNotExist = errors.New("note does not exist") + ErrCouldNotParseForm = errors.New("could not parse form") + ErrEncryptionKeyNotFound = errors.New("encryption key not found") + ErrCouldNotDecryptNote = errors.New("could not decrypt note") + ErrInvalidPassword = errors.New("invalid password") + ErrCouldNotParseFile = errors.New("could not parse file") + ErrFileTooLarge = errors.New("file too large") + ErrTextFileExpected = errors.New("text file expected") + ErrCouldNotReadFile = errors.New("could not read file") + ErrEmptyNote = errors.New("empty note") + ErrEncryptionRequired = errors.New("encryption is required") + ErrClientEncryptionKeyNotAllowed = errors.New("client encryption key is not allowed") + ErrInvalidExpiration = errors.New("invalid expiration") + ErrCouldNotCreateNote = errors.New("could not create note") + ErrCouldNotDecodePayload = errors.New("could not decode payload") + ErrCouldNotDecodeContent = errors.New("could not decode content") + ErrNoteIsPasswordProtected = errors.New("note is password protected") +) diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index 6393448..0c2dccf 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -24,29 +24,30 @@ func (e APIErrorResponse) ToJSON() string { return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err) } -func apiError(level int, w http.ResponseWriter, logger *slog.Logger, msg string, err error) { +func apiError(level int, w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + // Wrap error for logging if err != nil { - err = fmt.Errorf("%s: %w", msg, err) + err = fmt.Errorf("%v: %w", topLevelErr, err) } logger.Error(fmt.Sprintf("%v", err)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(level) fmt.Fprint(w, APIErrorResponse{ - Message: msg, + Message: fmt.Sprintf("%v", topLevelErr), }.ToJSON()) } -func APIError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - apiError(http.StatusInternalServerError, w, logger, msg, err) +func APIError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusInternalServerError, w, logger, topLevelErr, err) } -func APIErrorNotFound(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - apiError(http.StatusNotFound, w, logger, msg, err) +func APIErrorNotFound(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusNotFound, w, logger, topLevelErr, err) } -func APIErrorBadRequest(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - apiError(http.StatusBadRequest, w, logger, msg, err) +func APIErrorBadRequest(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusBadRequest, w, logger, topLevelErr, err) } func HealthHandler(w http.ResponseWriter, r *http.Request) { @@ -61,6 +62,10 @@ type CreateNoteHandler struct { allowNoEncryption bool } +func (h *CreateNoteHandler) Name() string { + return "CreateNoteHandler" +} + type CreateNotePayload struct { Content string `json:"content"` Password string `json:"password"` @@ -78,7 +83,7 @@ type CreateNoteResponse struct { func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - logger := h.logger.With("handler", "CreateNoteHandler") + logger := h.logger.With("handler", h.Name()) bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() @@ -86,30 +91,30 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var body CreateNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { - APIError(w, logger, "could not decode payload", err) + APIError(w, logger, ErrCouldNotDecodePayload, err) return } if !h.allowNoEncryption && !body.Encrypted { - APIError(w, logger, "encryption is mandatory", nil) + APIError(w, logger, ErrEncryptionRequired, nil) return } if !h.allowClientEncryptionKey && body.EncryptionKey != "" { - APIError(w, logger, "client encryption key is not allowed", nil) + APIError(w, logger, ErrClientEncryptionKeyNotAllowed, nil) return } content, err := internal.Decode(body.Content) if err != nil { - APIError(w, logger, "could not decode content", err) + APIError(w, logger, ErrCouldNotDecodeContent, err) return } note, err := h.db.Create(content, body.Password, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) if err != nil { - APIError(w, logger, "could not create note", err) + APIError(w, logger, ErrCouldNotCreateNote, err) return } @@ -122,6 +127,10 @@ type GetNoteHandler struct { db *Database } +func (h *GetNoteHandler) Name() string { + return "GetNoteHandler" +} + func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -129,14 +138,14 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { note, err := h.db.Get(id) - logger := h.logger.With("handler", "CreateNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) if err != nil { - APIError(w, logger, "could not find note", err) + APIError(w, logger, ErrCouldNotFindNote, err) } else if note == nil { - APIErrorNotFound(w, logger, "note does not exist", err) + APIErrorNotFound(w, logger, ErrNoteDoesNotExist, err) } else if note.PasswordHash != nil { - APIErrorBadRequest(w, logger, "note is password protected", err) + APIErrorBadRequest(w, logger, ErrNoteIsPasswordProtected, err) } else { if note.Encrypted { w.Header().Set("Content-Type", "application/octet-stream") @@ -157,13 +166,17 @@ type GetProtectedNotePayload struct { Password string `json:"password"` } +func (h *GetProtectedNoteHandler) Name() string { + return "GetProtectedNoteHandler" +} + func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") vars := mux.Vars(r) id := vars["id"] - logger := h.logger.With("handler", "GetProtectedNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() @@ -171,14 +184,14 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque var body GetProtectedNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { - APIError(w, logger, "could not decode payload", err) + APIError(w, logger, ErrCouldNotDecodePayload, err) return } note, err := h.db.Get(id) if err != nil { - APIError(w, logger, "could not find note", err) + APIError(w, logger, ErrCouldNotFindNote, err) return } else if note == nil { w.WriteHeader(http.StatusNotFound) @@ -188,15 +201,15 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque if body.EncryptionKey != "" && note.Encrypted { note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey) if err != nil { - APIError(w, logger, "could not decrypt note", err) + APIError(w, logger, ErrCouldNotDecryptNote, err) return } } - if body.Password == "" && (note.PasswordHash != nil || len(note.PasswordHash) > 0) { + if len(note.PasswordHash) > 0 { err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(body.Password)) if err != nil { - APIError(w, logger, "could not validate password", err) + APIError(w, logger, ErrInvalidPassword, err) return } } diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index 9679dac..5198194 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -36,13 +36,13 @@ type PageData struct { DisableEditor bool } -func TemplateError(w http.ResponseWriter, pageData PageData, templates *template.Template, templateName string, logger *slog.Logger, msg string, err error) { +func WebError(w http.ResponseWriter, pageData PageData, templates *template.Template, templateName string, logger *slog.Logger, topLevelErr error, err error) { // Only show the top-level error to users - pageData.Err = fmt.Errorf("%s", msg) + pageData.Err = topLevelErr // Show full error in the logs if err != nil { - err = fmt.Errorf("%s: %w", msg, err) + err = fmt.Errorf("%v: %w", topLevelErr, err) } else { err = pageData.Err } @@ -71,19 +71,23 @@ func (h *CreateNoteWithFormHandler) TemplateName() string { return "create" } -func (h *CreateNoteWithFormHandler) TemplateError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - TemplateError(w, h.PageData, h.Templates, h.TemplateName(), logger, msg, err) +func (h *CreateNoteWithFormHandler) Name() string { + return "CreateNoteWithFormHandler" +} + +func (h *CreateNoteWithFormHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - logger := h.logger.With("handler", "CreateNoteWithFormHandler") + logger := h.logger.With("handler", h.Name()) logger.Debug("parsing multipart form") err := r.ParseMultipartForm(h.maxUploadSize) if err != nil { - h.TemplateError(w, logger, "could not parse form", err) + h.WebError(w, logger, ErrCouldNotParseForm, err) return } @@ -93,46 +97,46 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req logger.Debug("parsing file") file, handler, err := r.FormFile("file") if err != nil && !errors.Is(err, http.ErrMissingFile) { - h.TemplateError(w, logger, "could not parse file", err) + h.WebError(w, logger, ErrCouldNotParseFile, err) return } if !errors.Is(err, http.ErrMissingFile) { defer file.Close() - h.logger.Debug("checking file size") + logger.Debug("checking file size") if handler.Size > h.maxUploadSize { - h.TemplateError(w, logger, "file too large", err) + h.WebError(w, logger, ErrFileTooLarge, err) return } - h.logger.Debug("checking file content type") + logger.Debug("checking file content type") if !strings.HasPrefix(handler.Header.Get("Content-Type"), "text/") { - h.TemplateError(w, logger, "text file expected", err) + h.WebError(w, logger, ErrTextFileExpected, err) return } - h.logger.Debug("reading uploaded file") + logger.Debug("reading uploaded file") var fileContent bytes.Buffer n, err := io.Copy(&fileContent, file) if err != nil { - h.TemplateError(w, logger, "could not read file", err) + h.WebError(w, logger, ErrCouldNotReadFile, err) return } - h.logger.Debug("file uploaded", slog.Any("bytes", n)) + logger.Debug("file uploaded", slog.Any("bytes", n)) if n != 0 { content = fileContent.Bytes() } } - h.logger.Debug("checking content") + logger.Debug("checking content") if content == nil || len(content) == 0 { - h.TemplateError(w, logger, "empty note", nil) + h.WebError(w, logger, ErrEmptyNote, nil) return } - h.logger.Debug("checking inputs") + logger.Debug("checking inputs") password := r.FormValue("password") noEncryption := r.FormValue("no-encryption") encryptionKey := r.FormValue("encryption-key") @@ -141,11 +145,11 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req language := r.FormValue("language") if !h.PageData.AllowNoEncryption && noEncryption != "" { - h.TemplateError(w, logger, "encryption is required", nil) + h.WebError(w, logger, ErrEncryptionRequired, nil) } if !h.PageData.AllowClientEncryptionKey && encryptionKey != "" { - h.TemplateError(w, logger, "client encryption key is not allowed", nil) + h.WebError(w, logger, ErrClientEncryptionKeyNotAllowed, nil) } if !h.PageData.AllowClientEncryptionKey && encryptionKey == "" && noEncryption == "" { @@ -160,7 +164,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } else { expirationInt, err = strconv.Atoi(expiration) if err != nil { - h.TemplateError(w, logger, "invalid expiration", err) + h.WebError(w, logger, ErrInvalidExpiration, err) return } } @@ -168,11 +172,11 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req logger.Debug("saving note to the database") note, err := h.db.Create(content, password, encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language) if err != nil { - h.TemplateError(w, logger, "could not create note", err) + h.WebError(w, logger, ErrCouldNotCreateNote, err) return } - h.logger.Debug("building note url") + logger.Debug("building note url") var scheme = "http://" if r.TLS != nil { scheme = "https://" @@ -183,7 +187,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req h.PageData.URL += "#" + encryptionKey } - h.logger.Debug("rendering page") + logger.Debug("rendering page") h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) } @@ -198,8 +202,12 @@ func (h *GetRawWebNoteHandler) TemplateName() string { return "unprotectedNote" } -func (h *GetRawWebNoteHandler) TemplateError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - TemplateError(w, h.PageData, h.Templates, h.TemplateName(), logger, msg, err) +func (h *GetRawWebNoteHandler) Name() string { + return "GetRawWebNoteHandler" +} + +func (h *GetRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -208,18 +216,18 @@ func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) vars := mux.Vars(r) id := vars["id"] - logger := h.logger.With("handler", "GetRawWebNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) logger.Debug("fetching note from the database") note, err := h.db.Get(id) if err != nil { - h.TemplateError(w, logger, "could not find note", err) + h.WebError(w, logger, ErrCouldNotFindNote, err) return } if note == nil { - h.TemplateError(w, logger, "note does not exist", err) + h.WebError(w, logger, ErrNoteDoesNotExist, err) return } @@ -248,8 +256,12 @@ func (h *GetProtectedRawWebNoteHandler) TemplateName() string { return "protectedNote" } -func (h *GetProtectedRawWebNoteHandler) TemplateError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - TemplateError(w, h.PageData, h.Templates, h.TemplateName(), logger, msg, err) +func (h *GetProtectedRawWebNoteHandler) Name() string { + return "GetProtectedRawWebNoteHandler" +} + +func (h *GetProtectedRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -258,12 +270,12 @@ func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http vars := mux.Vars(r) id := vars["id"] - logger := h.logger.With("handler", "GetProtectedRawWebNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) logger.Debug("parsing multipart form") err := r.ParseMultipartForm(h.maxUploadSize) if err != nil { - h.TemplateError(w, logger, "could not parse form", err) + h.WebError(w, logger, ErrCouldNotParseForm, err) return } @@ -274,24 +286,24 @@ func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http note, err := h.db.Get(id) if err != nil { - h.TemplateError(w, logger, "could not find note", err) + h.WebError(w, logger, ErrCouldNotFindNote, err) return } if note == nil { - h.TemplateError(w, logger, "note does not exist", nil) + h.WebError(w, logger, ErrNoteDoesNotExist, nil) return } if note.Encrypted { if encryptionKey == "" { - h.TemplateError(w, logger, "encryption key not found", nil) + h.WebError(w, logger, ErrEncryptionKeyNotFound, nil) return } logger.Debug("decrypting content") note.Content, err = internal.Decrypt(note.Content, encryptionKey) if err != nil { - h.TemplateError(w, logger, "could not decrypt note", err) + h.WebError(w, logger, ErrCouldNotDecryptNote, err) return } } @@ -299,7 +311,7 @@ func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http if len(note.PasswordHash) > 0 { logger.Debug("comparing password hashes") if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { - h.TemplateError(w, logger, "invalid password", err) + h.WebError(w, logger, ErrInvalidPassword, err) return } } @@ -321,8 +333,12 @@ func (h *GetWebNoteHandler) TemplateName() string { return "unprotectedNote" } -func (h *GetWebNoteHandler) TemplateError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - TemplateError(w, h.PageData, h.Templates, h.TemplateName(), logger, msg, err) +func (h *GetWebNoteHandler) Name() string { + return "GetWebNoteHandler" +} + +func (h *GetWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -331,23 +347,23 @@ func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] - logger := h.logger.With("handler", "GetWebNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) note, err := h.db.Get(id) if err != nil { - h.TemplateError(w, logger, "could not find note", err) + h.WebError(w, logger, ErrCouldNotFindNote, err) return } if note == nil { - h.TemplateError(w, logger, "note does not exist", nil) + h.WebError(w, logger, ErrNoteDoesNotExist, nil) return } h.PageData.Note = note - h.logger.Debug("rendering page") + logger.Debug("rendering page") h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) } @@ -363,8 +379,12 @@ func (h *GetProtectedWebNoteHandler) TemplateName() string { return "protectedNote" } -func (h *GetProtectedWebNoteHandler) TemplateError(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { - TemplateError(w, h.PageData, h.Templates, h.TemplateName(), logger, msg, err) +func (h *GetProtectedWebNoteHandler) Name() string { + return "GetProtectedWebNoteHandler" +} + +func (h *GetProtectedWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -373,12 +393,12 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re vars := mux.Vars(r) id := vars["id"] - logger := h.logger.With("handler", "GetProtectedWebNoteHandler", "note_id", id) + logger := h.logger.With("handler", h.Name(), "note_id", id) - h.logger.Debug("parsing multipart form") + logger.Debug("parsing multipart form") err := r.ParseMultipartForm(h.maxUploadSize) if err != nil { - h.TemplateError(w, logger, "could not parse form", err) + h.WebError(w, logger, ErrCouldNotParseForm, err) return } @@ -388,37 +408,37 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re note, err := h.db.Get(id) if err != nil { - h.TemplateError(w, logger, "could not find note", err) + h.WebError(w, logger, ErrCouldNotFindNote, err) return } if note == nil { - h.TemplateError(w, logger, "note does not exist", nil) + h.WebError(w, logger, ErrNoteDoesNotExist, nil) return } if note.Encrypted { if encryptionKey == "" { - h.TemplateError(w, logger, "encryption key not found", nil) + h.WebError(w, logger, ErrEncryptionKeyNotFound, nil) return } note.Content, err = internal.Decrypt(note.Content, encryptionKey) if err != nil { - h.TemplateError(w, logger, "could not decrypt note", err) + h.WebError(w, logger, ErrCouldNotDecryptNote, err) return } } if len(note.PasswordHash) > 0 { if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { - h.TemplateError(w, logger, "invalid password", err) + h.WebError(w, logger, ErrInvalidPassword, err) return } } h.PageData.Note = note - h.logger.Debug("rendering page") + logger.Debug("rendering page") h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) }