diff --git a/README.md b/README.md index 32461be..43534dd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -sips +SIPS ==== [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/DeedleFake/sips)](https://pkg.go.dev/github.com/DeedleFake/sips) [![Go Report Card](https://goreportcard.com/badge/github.com/DeedleFake/sips)](https://goreportcard.com/report/github.com/DeedleFake/sips) -*Disclaimer: sips is still in early development and is not guaranteed to do much of anything. Although it should function for basic usage, expect bugs, and definitely don't use it for anything that has money associated with it.* +*Disclaimer: SIPS is still in early development and is not guaranteed to do much of anything. Although it should function for basic usage, expect bugs, and definitely don't use it for anything that has money associated with it.* -sips is a Simple IPFS Pinning Service. It does the bare minimum necessary to present a functional [pinning service][pinning-service-api]. +SIPS is a Simple IPFS Pinning Service. It does the bare minimum necessary to present a functional [pinning service][pinning-service-api]. Setup ----- -After installation, sips will have no users or tokens in its database. To create some, use the `sipsctl` utility that is provided: +After installation, SIPS will have no users or tokens in its database. To create some, use the `sipsctl` utility that is provided: ```bash $ sipsctl users add whateverUsernameYouWant diff --git a/cmd/sips/errors.go b/cmd/sips/errors.go new file mode 100644 index 0000000..101b896 --- /dev/null +++ b/cmd/sips/errors.go @@ -0,0 +1,41 @@ +package main + +import "net/http" + +type statusError struct { + StatusCode int + Err error +} + +func Unauthorized(err error) error { + return statusError{ + StatusCode: http.StatusUnauthorized, + Err: err, + } +} + +func NotFound(err error) error { + return statusError{ + StatusCode: http.StatusNotFound, + Err: err, + } +} + +func BadRequest(err error) error { + return statusError{ + StatusCode: http.StatusBadRequest, + Err: err, + } +} + +func (err statusError) Error() string { + return err.Err.Error() +} + +func (err statusError) Unwrap() error { + return err.Err +} + +func (err statusError) Status() int { + return err.StatusCode +} diff --git a/cmd/sips/pinhandler.go b/cmd/sips/pinhandler.go index eb59e32..fa321c4 100644 --- a/cmd/sips/pinhandler.go +++ b/cmd/sips/pinhandler.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io/fs" "strconv" "time" @@ -23,15 +22,13 @@ type PinHandler struct { func (h PinHandler) Pins(ctx context.Context, query sips.PinQuery) ([]sips.PinStatus, error) { tx, err := h.DB.Begin(false) if err != nil { - log.Errorf("begin transaction: %v", err) - return nil, err + return nil, log.Errorf("begin transaction: %w", err) } defer tx.Rollback() user, err := auth(ctx, tx) if err != nil { - log.Errorf("authenticate: %v", err) - return nil, err + return nil, Unauthorized(log.Errorf("authenticate: %w", err)) } selector := tx.Select(&queryMatcher{Query: query}) @@ -42,8 +39,7 @@ func (h PinHandler) Pins(ctx context.Context, query sips.PinQuery) ([]sips.PinSt var dbpins []dbs.Pin err = selector.OrderBy("Created").Find(&dbpins) if (err != nil) && (!errors.Is(err, storm.ErrNotFound)) { - log.Errorf("find pins for %v: %v", user.Name, err) - return nil, err + return nil, log.Errorf("find pins for %v: %w", user.Name, err) } pins := make([]sips.PinStatus, 0, len(dbpins)) @@ -65,15 +61,13 @@ func (h PinHandler) Pins(ctx context.Context, query sips.PinQuery) ([]sips.PinSt func (h PinHandler) AddPin(ctx context.Context, pin sips.Pin) (sips.PinStatus, error) { tx, err := h.DB.Begin(true) if err != nil { - log.Errorf("begin transaction: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("begin transaction: %w", err) } defer tx.Rollback() user, err := auth(ctx, tx) if err != nil { - log.Errorf("authenticate: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, Unauthorized(log.Errorf("authenticate: %w", err)) } dbpin := dbs.Pin{ @@ -84,8 +78,7 @@ func (h PinHandler) AddPin(ctx context.Context, pin sips.Pin) (sips.PinStatus, e } err = tx.Save(&dbpin) if err != nil { - log.Errorf("save pin %q: %v", pin.CID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("save pin %q: %w", pin.CID, err) } if len(pin.Origins) != 0 { @@ -96,13 +89,12 @@ func (h PinHandler) AddPin(ctx context.Context, pin sips.Pin) (sips.PinStatus, e _, err = h.IPFS.PinAdd(ctx, pin.CID) if err != nil { - log.Errorf("add pin %v: %v", pin.CID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("add pin %v: %w", pin.CID, err) } id, err := h.IPFS.ID(ctx) if err != nil { - log.Errorf("get IPFS id: %v", err) + log.Errorf("get IPFS id: %w", err) // Purposefully don't return here. } @@ -118,33 +110,32 @@ func (h PinHandler) AddPin(ctx context.Context, pin sips.Pin) (sips.PinStatus, e func (h PinHandler) GetPin(ctx context.Context, requestID string) (sips.PinStatus, error) { pinID, err := strconv.ParseUint(requestID, 16, 64) if err != nil { - log.Errorf("parse request ID %q: %v", requestID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, BadRequest(log.Errorf("parse request ID %q: %w", requestID, err)) } tx, err := h.DB.Begin(false) if err != nil { - log.Errorf("begin transaction: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("begin transaction: %w", err) } defer tx.Rollback() user, err := auth(ctx, tx) if err != nil { - log.Errorf("authenticate: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, Unauthorized(log.Errorf("authenticate: %w", err)) } var pin dbs.Pin err = tx.One("ID", pinID, &pin) if err != nil { - log.Errorf("find pin %v: %v", requestID, err) + err = log.Errorf("find pin %v: %w", requestID, err) + if errors.Is(err, storm.ErrNotFound) { + err = NotFound(err) + } return sips.PinStatus{}, err } if pin.User != user.ID { - log.Errorf("user %v is not authorized to see pin %v", user.Name, requestID) - return sips.PinStatus{}, fs.ErrPermission + return sips.PinStatus{}, NotFound(log.Errorf("find pin %v: %w", requestID, storm.ErrNotFound)) } return sips.PinStatus{ @@ -161,42 +152,40 @@ func (h PinHandler) GetPin(ctx context.Context, requestID string) (sips.PinStatu func (h PinHandler) UpdatePin(ctx context.Context, requestID string, pin sips.Pin) (sips.PinStatus, error) { pinID, err := strconv.ParseUint(requestID, 16, 64) if err != nil { - log.Errorf("parse request ID: %q: %v", requestID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, BadRequest(log.Errorf("parse request ID: %q: %w", requestID, err)) } tx, err := h.DB.Begin(true) if err != nil { - log.Errorf("begin transaction: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("begin transaction: %w", err) } defer tx.Rollback() user, err := auth(ctx, tx) if err != nil { - log.Errorf("authenticate: %v", err) - return sips.PinStatus{}, err + return sips.PinStatus{}, NotFound(log.Errorf("authenticate: %w", err)) } var dbpin dbs.Pin err = tx.One("ID", pinID, &dbpin) if err != nil { - log.Errorf("find pin %v: %v", requestID, err) + err = log.Errorf("find pin %v: %w", requestID, err) + if errors.Is(err, storm.ErrNotFound) { + err = NotFound(err) + } return sips.PinStatus{}, err } oldCID := dbpin.CID if dbpin.User != user.ID { - log.Errorf("user %v not allowed to update pin %v", user.Name, requestID) - return sips.PinStatus{}, fs.ErrPermission + return sips.PinStatus{}, NotFound(log.Errorf("find pin %v: %w", requestID, storm.ErrNotFound)) } dbpin.Name = pin.Name dbpin.CID = pin.CID err = tx.Update(&dbpin) if err != nil { - log.Errorf("update pin %v: %v", requestID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("update pin %v: %w", requestID, err) } if len(pin.Origins) != 0 { @@ -207,13 +196,12 @@ func (h PinHandler) UpdatePin(ctx context.Context, requestID string, pin sips.Pi _, err = h.IPFS.PinUpdate(ctx, oldCID, pin.CID, false) if err != nil { - log.Errorf("add pin %v: %v", pin.CID, err) - return sips.PinStatus{}, err + return sips.PinStatus{}, log.Errorf("add pin %v: %w", pin.CID, err) } id, err := h.IPFS.ID(ctx) if err != nil { - log.Errorf("get IPFS id: %v", err) + log.Errorf("get IPFS id: %w", err) // Purposefully don't return here. } @@ -232,45 +220,42 @@ func (h PinHandler) UpdatePin(ctx context.Context, requestID string, pin sips.Pi func (h PinHandler) DeletePin(ctx context.Context, requestID string) error { pinID, err := strconv.ParseUint(requestID, 16, 64) if err != nil { - log.Errorf("parse request ID %q: %v", requestID, err) - return err + return BadRequest(log.Errorf("parse request ID %q: %w", requestID, err)) } tx, err := h.DB.Begin(true) if err != nil { - log.Errorf("begin transaction: %v", err) - return err + return log.Errorf("begin transaction: %w", err) } defer tx.Rollback() user, err := auth(ctx, tx) if err != nil { - log.Errorf("authenticate: %v", err) - return err + return Unauthorized(log.Errorf("authenticate: %w", err)) } var pin dbs.Pin err = tx.One("ID", pinID, &pin) if err != nil { - log.Errorf("find pin %v: %v", requestID, err) + err = log.Errorf("find pin %v: %w", requestID, err) + if errors.Is(err, storm.ErrNotFound) { + err = NotFound(err) + } return err } if pin.User != user.ID { - log.Errorf("user %v is not authorized to delete pin %v", user.Name, requestID) - return fs.ErrPermission // TODO: That's just not right. + return NotFound(log.Errorf("find pin %v: %w", requestID, storm.ErrNotFound)) } err = tx.DeleteStruct(&pin) if err != nil { - log.Errorf("delete pin %v: %v", requestID, err) - return err + return log.Errorf("delete pin %v: %w", requestID, err) } _, err = h.IPFS.PinRm(ctx, pin.CID) if err != nil { - log.Errorf("unpin %v: %v", pin.CID, err) - return err + return log.Errorf("unpin %v: %w", pin.CID, err) } return tx.Commit() diff --git a/cmd/sips/sips.go b/cmd/sips/sips.go index ac0721e..5b99d03 100644 --- a/cmd/sips/sips.go +++ b/cmd/sips/sips.go @@ -1,3 +1,4 @@ +// sips is the primary implementation of a simple pinning service daemon. package main import ( diff --git a/cmd/sipsctl/sipsctl.go b/cmd/sipsctl/sipsctl.go index 2e0af03..c24f5ef 100644 --- a/cmd/sipsctl/sipsctl.go +++ b/cmd/sipsctl/sipsctl.go @@ -1,3 +1,4 @@ +// sipsctl is a simple utility for administrating the database used by SIPS. package main import ( diff --git a/dbs/dbs.go b/dbs/dbs.go index e09a0d6..a7a2763 100644 --- a/dbs/dbs.go +++ b/dbs/dbs.go @@ -6,6 +6,8 @@ import ( "github.com/asdine/storm" ) +// Open opens the database, initializing all of the required top-level +// buckets. func Open(path string) (*storm.DB, error) { db, err := storm.Open(path) if err != nil { diff --git a/dbs/schema.go b/dbs/schema.go index dc9fcea..de83840 100644 --- a/dbs/schema.go +++ b/dbs/schema.go @@ -4,18 +4,21 @@ import ( "time" ) +// User reprsents a user in the database. type User struct { ID uint64 `storm:"increment"` Created time.Time Name string `storm:"index,unique"` } +// Token represents an auth token in the database. type Token struct { ID string Created time.Time User uint64 `storm:"index"` } +// Pin represents a pin entry in the database. type Pin struct { ID uint64 `storm:"increment"` Created time.Time diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..fab49ad --- /dev/null +++ b/doc.go @@ -0,0 +1,8 @@ +// Package sips provides structures for implementing an IPFS pinning service. +// +// The primary purpose of this package is to allow a user to create an +// IPFS pinning service with minimal effort. The package is based +// around the PinHandler interface. An implementation of this +// interface can be passed to the Handler function in order to create +// an HTTP handler that serves a valid pinning service. +package sips diff --git a/handler.go b/handler.go index 13fd429..5b81594 100644 --- a/handler.go +++ b/handler.go @@ -3,6 +3,7 @@ package sips import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -13,6 +14,12 @@ import ( "github.com/gorilla/mux" ) +var ( + errNoToken = errors.New("no bearer token provided") + errInvalidStatusQuery = errors.New("status list must be non-empty and have at most 4 elements") + errNoRequestID = errors.New("request ID is required") +) + type ctxKeyToken struct{} func withToken(ctx context.Context, token string) context.Context { @@ -44,6 +51,17 @@ func tokenFromRequest(req *http.Request) (string, bool) { // PinHandler is an interface satisfied by types that can be used to // handle pinning service requests. +// +// Every method is called after the authentication token is pulled +// from HTTP headers, so it can be assumed that a token is included in +// the provided context. It should not, however, be assumed that the +// token is valid. +// +// Errors returned by a PinHandler's methods are returned to the +// client verbatim, so implementations should be careful not to +// include data that shouldn't be shown to clients in them. If an +// error implements StatusError, the HTTP status code returned will be +// whatever the error's Status method returns. type PinHandler interface { // Pins returns a list of pinning request statuses based on the // given query. @@ -89,7 +107,7 @@ func Handler(h PinHandler) http.Handler { respondError( rw, http.StatusUnauthorized, - "no bearer token provided", + errNoToken, ) return } @@ -119,7 +137,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("too many CIDs: %v", len(query.CID)), + fmt.Errorf("too many CIDs: %v", len(query.CID)), ) return } @@ -132,7 +150,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("invalid matching strategy: %q", match), + fmt.Errorf("invalid matching strategy: %q", match), ) return } @@ -144,7 +162,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - "status list must be non-empty and have at most 4 elements", + errInvalidStatusQuery, ) return } @@ -160,7 +178,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("invalid before %q: %v", before, err), + fmt.Errorf("invalid before %q: %w", before, err), ) return } @@ -174,7 +192,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("invalid after %q: %v", after, err), + fmt.Errorf("invalid after %q: %w", after, err), ) return } @@ -187,7 +205,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("invalid limit %q: %v", limit, err), + fmt.Errorf("invalid limit %q: %w", limit, err), ) return } @@ -201,7 +219,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("invalid meta %q: %v", meta, err), + fmt.Errorf("invalid meta %q: %w", meta, err), ) return } @@ -209,7 +227,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { pins, err := h.h.Pins(ctx, query) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } @@ -221,7 +239,7 @@ func (h handler) getPins(rw http.ResponseWriter, req *http.Request) { Results: pins, }) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } } @@ -231,7 +249,7 @@ func (h handler) postPins(rw http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(req.Body) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } @@ -241,20 +259,20 @@ func (h handler) postPins(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("failed to parse body: %v", err), + fmt.Errorf("failed to parse body: %w", err), ) return } status, err := h.h.AddPin(ctx, pin) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } err = json.NewEncoder(rw).Encode(status) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } } @@ -268,20 +286,20 @@ func (h handler) getPinByID(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - "request ID is required", + errNoRequestID, ) return } status, err := h.h.GetPin(ctx, id) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } err = json.NewEncoder(rw).Encode(status) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } } @@ -295,14 +313,14 @@ func (h handler) postPinByID(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - "request ID is required", + errNoRequestID, ) return } body, err := io.ReadAll(req.Body) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } @@ -312,20 +330,20 @@ func (h handler) postPinByID(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - fmt.Sprintf("failed to parse body: %v", err), + fmt.Errorf("failed to parse body: %w", err), ) return } status, err := h.h.UpdatePin(ctx, id, pin) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } err = json.NewEncoder(rw).Encode(status) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } } @@ -339,14 +357,14 @@ func (h handler) deletePinByID(rw http.ResponseWriter, req *http.Request) { respondError( rw, http.StatusBadRequest, - "request ID is required", + errNoRequestID, ) return } err := h.h.DeletePin(ctx, id) if err != nil { - respondError(rw, http.StatusInternalServerError, "") + respondError(rw, http.StatusInternalServerError, err) return } @@ -362,13 +380,18 @@ type errorResponseError struct { Details string `json:"details,omitempty"` } -func respondError(rw http.ResponseWriter, status int, err string) { +func respondError(rw http.ResponseWriter, status int, err error) { + var statusError StatusError + if errors.As(err, &statusError) { + status = statusError.Status() + } + rw.WriteHeader(status) json.NewEncoder(rw).Encode(errorResponse{ Error: errorResponseError{ Reason: reasonFromStatus(status), - Details: err, + Details: err.Error(), }, }) } @@ -391,3 +414,20 @@ func reasonFromStatus(status int) string { return "INTERNAL_SERVER_ERROR" } } + +// StatusError is implemented by errors returned by PinHandler +// implementations that want to send custom status codes to the +// client. +// +// Several status codes have special handling. These include +// - 400 Bad Request +// - 401 Unauthorized +// - 404 Not Found +// - 409 Conflict +// +// These status codes will produce special error messages for the +// client. All other status codes will produce the same error message +// as a 500 Internal Server Error code does. +type StatusError interface { + Status() int +} diff --git a/internal/log/log.go b/internal/log/log.go index af2de70..9111a10 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -36,16 +36,23 @@ func getLocation() string { return fmt.Sprintf("%v:%v", file, line) } +// Infof logs an informational message. func Infof(str string, args ...interface{}) { loc := getLocation() - info.Printf("(%v) %v", loc, fmt.Sprintf(str, args...)) + msg := fmt.Sprintf(str, args...) + info.Printf("(%v) %v", loc, msg) } -func Errorf(str string, args ...interface{}) { +// Errorf logs an error. As a special case, it returns a new error +// constructed from its arguments via fmt.Errorf. +func Errorf(str string, args ...interface{}) error { loc := getLocation() - err.Printf("(%v) %v", loc, fmt.Sprintf(str, args...)) + nerr := fmt.Errorf(str, args...) + err.Printf("(%v) %v", loc, nerr) + return nerr } +// Fatalf logs a fatal error and immediately exits. func Fatalf(str string, args ...interface{}) { loc := getLocation() fatal.Fatalf("(%v) %v", loc, fmt.Sprintf(str, args...))