1
0
Fork 0
timelinize/tlzapp/errors.go

167 lines
5 KiB
Go

/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package tlzapp
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
mathrand "math/rand"
"net/http"
"strconv"
"strings"
"github.com/timelinize/timelinize/timeline"
"go.uber.org/zap"
)
// Error is a JSON-serializable representation of an error.
type Error struct {
Err error `json:"-"`
HTTPStatus int `json:"http_status"` // recommended HTTP status to send to the client
Log string `json:"-"` // optional; for logs, technical context in which the error was produced
Message string `json:"message,omitempty"` // optional; a human-readable sentence
Recommendations []string `json:"recommendations,omitempty"` // optional
Data any `json:"data,omitempty"` // optional; any extra data that should be included or handled specially
// generated; don't fill these out
ID string `json:"id,omitempty"` // for associating log entries
ErrString string `json:"error"` // to ensure string serialization
}
func (e Error) Error() string {
var msg strings.Builder
if e.Log != "" {
msg.WriteString(e.Log)
if e.Err != nil {
msg.WriteString(": ")
}
}
if e.Err != nil {
msg.WriteString(e.Err.Error())
}
if e.Message != "" {
msg.WriteString(fmt.Sprintf(" (%s)", e.Message))
}
if e.ID != "" {
msg.WriteString(fmt.Sprintf(" {id=%s}", e.ID))
}
return msg.String()
}
func httpStatusfromOSErr(err error, defaultStatus int) int {
switch {
case errors.Is(err, fs.ErrPermission):
return http.StatusForbidden
case errors.Is(err, fs.ErrNotExist):
return http.StatusNotFound
}
return defaultStatus
}
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var errVal Error
if !errors.As(err, &errVal) {
errVal = Error{
Err: err,
Log: "error was not well-structured",
}
}
// give this error a unique ID so we can investigate bug reports more easily
errVal.ID = newErrorID()
// ensure error is serialized as a string when written to the client
if errVal.Err != nil {
errVal.ErrString = errVal.Err.Error()
}
// see if we can fill in some default values if they're missing
if errVal.HTTPStatus == 0 {
errVal.HTTPStatus = httpStatusfromOSErr(err, http.StatusInternalServerError)
}
if errVal.Message == "" && errVal.Err != nil {
errVal.Message = errVal.Err.Error()
}
// append standard recommendations
reportRec := "If it still doesn't work, your server administrator or application host (whoever set up the app for you to use) can help! Please report this bug "
reportRecEnd := "to them and they'll take care of it."
if errVal.ID == "" {
reportRec += reportRecEnd
} else {
reportRec += fmt.Sprintf("along with this magic incantation: %s %s", errVal.ID, reportRecEnd)
}
errVal.Recommendations = append(errVal.Recommendations,
"Make any relevant changes, then try again.",
reportRec,
)
// log the error
timeline.Log.Named("http").Error(errVal.Log,
zap.Error(errVal.Err),
zap.Int("status", errVal.HTTPStatus),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("error_id", errVal.ID),
zap.Any("data", errVal.Data),
)
// write the error to the HTTP response for the frontend
jsonBytes, err := json.Marshal(errVal)
if err != nil {
timeline.Log.Error("encoding error response",
zap.Error(err),
zap.String("original_error", errVal.Error()))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(jsonBytes)))
status := errVal.HTTPStatus
if status < http.StatusOK {
status = http.StatusInternalServerError
}
w.WriteHeader(status)
_, _ = w.Write(jsonBytes)
}
func newErrorID() string {
const idLen = 8
return randString(idLen, true)
}
// randString returns a string of n random characters.
// It is not even remotely secure or a proper distribution.
// But it's good enough for some things. It excludes certain
// confusing characters like I, l, 1, 0, O, etc., and a couple
// vowels to avoid most profanities.
func randString(n int, lowerCase bool) string {
if n <= 0 {
return ""
}
dict := []byte("abcdefghjkmnopqrstvwxyzABCDEFGHJKLMNPQRTUVWXY23456789")
if lowerCase {
dict = []byte("abcdefghjkmnpqrstvwxyz23456789")
}
b := make([]byte, n)
for i := range b {
b[i] = dict[mathrand.Int63()%int64(len(dict))] //nolint:gosec
}
return string(b)
}