167 lines
5 KiB
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)
|
|
}
|