From dfc116d8289f356ea56d9b71bfd78df84d8ee7bc Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 6 Sep 2023 02:23:06 +0200 Subject: [PATCH] add API boilerplate + /accounts/{accountID} and /accounts/@me endpoints --- web/api/{user.go => account.go} | 0 web/api/accounts/get_account.go | 48 +++++++++ web/api/accounts/module.go | 13 +++ web/api/blog.go | 36 +++++++ web/api/error.go | 166 ++++++++++++++++++++++++++++++++ web/app/middleware.go | 59 ++++++++++++ web/routes.go | 13 +++ 7 files changed, 335 insertions(+) rename web/api/{user.go => account.go} (100%) create mode 100644 web/api/accounts/get_account.go create mode 100644 web/api/accounts/module.go create mode 100644 web/api/blog.go create mode 100644 web/api/error.go diff --git a/web/api/user.go b/web/api/account.go similarity index 100% rename from web/api/user.go rename to web/api/account.go diff --git a/web/api/accounts/get_account.go b/web/api/accounts/get_account.go new file mode 100644 index 0000000..691090d --- /dev/null +++ b/web/api/accounts/get_account.go @@ -0,0 +1,48 @@ +package accounts + +import ( + "net/http" + + "git.sleepycat.moe/sam/mercury/internal/database/sql" + "git.sleepycat.moe/sam/mercury/web/api" + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + "github.com/rs/zerolog/log" +) + +func (app *App) GetID(w http.ResponseWriter, r *http.Request) (any, error) { + ctx := r.Context() + + accountID, err := ulid.Parse(chi.URLParam(r, "accountID")) + if err != nil { + return nil, api.Error{Code: api.ErrAccountNotFound} + } + + acct, err := app.Account().ByID(ctx, accountID) + if err != nil { + if err == sql.ErrNotFound { + return nil, api.Error{Code: api.ErrAccountNotFound} + } + + log.Err(err).Str("id", accountID.String()).Msg("fetching user from database") + return nil, err + } + + token, ok := app.TokenFromContext(ctx) + if ok && token.UserID == acct.ID { + return api.DBAccountToSelfAccount(acct), nil + } + return api.DBAccountToAccount(acct), nil +} + +func (app *App) GetMe(w http.ResponseWriter, r *http.Request) (api.SelfAccount, error) { + ctx := r.Context() + token, _ := app.TokenFromContext(ctx) // Token will always be available + + acct, err := app.Account().ByID(ctx, token.UserID) + if err != nil { + log.Err(err).Str("id", token.UserID.String()).Msg("fetching user from database") + return api.SelfAccount{}, err + } + return api.DBAccountToSelfAccount(acct), nil +} diff --git a/web/api/accounts/module.go b/web/api/accounts/module.go new file mode 100644 index 0000000..e81070c --- /dev/null +++ b/web/api/accounts/module.go @@ -0,0 +1,13 @@ +package accounts + +import "git.sleepycat.moe/sam/mercury/web/app" + +type App struct { + *app.App +} + +func New(app *app.App) *App { + return &App{ + App: app, + } +} diff --git a/web/api/blog.go b/web/api/blog.go new file mode 100644 index 0000000..e2ee41e --- /dev/null +++ b/web/api/blog.go @@ -0,0 +1,36 @@ +package api + +import ( + "git.sleepycat.moe/sam/mercury/internal/database" + "github.com/oklog/ulid/v2" +) + +// Blog is the basic blog returned by endpoints. +type Blog struct { + ID ulid.ULID `json:"id"` + Name string `json:"name"` + Domain *string `json:"domain"` + Bio string `json:"bio"` + + Account blogPartialAccount `json:"account"` +} + +type blogPartialAccount struct { + ID ulid.ULID `json:"id"` + Username string `json:"username"` + Domain *string `json:"domain"` +} + +func DBBlogToBlog(b database.Blog, a database.Account) Blog { + return Blog{ + ID: b.ID, + Name: b.Name, + Domain: b.Domain, + Bio: b.Bio, + Account: blogPartialAccount{ + ID: a.ID, + Username: a.Username, + Domain: a.Domain, + }, + } +} diff --git a/web/api/error.go b/web/api/error.go new file mode 100644 index 0000000..226ffd3 --- /dev/null +++ b/web/api/error.go @@ -0,0 +1,166 @@ +package api + +import ( + "fmt" + "net/http" + + "emperror.dev/errors" + "github.com/go-chi/render" +) + +// NoValue should be returned by functions that either: +// - don't return any content for one or more code paths +// - handle rendering content themselves +const NoValue = errors.Sentinel("no content") + +// WrapHandlerT wraps a modified http.HandlerFunc into a stdlib-compatible one. +// The inner HandlerFunc additionally returns a JSON response and an error. +func WrapHandlerT[T any](hn func(w http.ResponseWriter, r *http.Request) (T, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + v, err := hn(w, r) + if err == nil { + render.JSON(w, r, v) + return + } else if err == NoValue { + return + } + + // if the function returned an API error, just render that verbatim + if apiErr, ok := err.(Error); ok { + apiErr.prepare() + + render.Status(r, apiErr.Status) + render.JSON(w, r, apiErr) + return + } + + // otherwise, we return an internal server error message + apiErr := Error{Code: ErrInternalServerError} + apiErr.prepare() + + render.Status(r, apiErr.Status) + render.JSON(w, r, apiErr) + } +} + +// WrapHandler wraps a modified http.HandlerFunc into a stdlib-compatible one. +// The inner HandlerFunc additionally returns an error. +func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + fn := func(w http.ResponseWriter, r *http.Request) (any, error) { + err := hn(w, r) + if err == nil { + return nil, NoValue + } + return nil, err + } + + return WrapHandlerT(fn) + + // return func(w http.ResponseWriter, r *http.Request) { + // err := hn(w, r) + // if err != nil { + // // if the function returned an API error, just render that verbatim + // // we can assume that it also logged the error (if that was needed) + // if apiErr, ok := err.(Error); ok { + // apiErr.prepare() + + // render.Status(r, apiErr.Status) + // render.JSON(w, r, apiErr) + // return + // } + + // // otherwise, we log the error and return an internal server error message + // log.Err(err).Msg("error in http handler") + + // apiErr := Error{Code: ErrInternalServerError} + // apiErr.prepare() + + // render.Status(r, apiErr.Status) + // render.JSON(w, r, apiErr) + // } + // } +} + +type Error struct { + Code int `json:"code"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` + + // Status is set as the HTTP status code. + Status int `json:"-"` +} + +func (e Error) Error() string { + if e.Message == "" { + e.Message = errCodeMessages[e.Code] + } + + if e.Details != "" { + return fmt.Sprintf("%s (code: %d) (%s)", e.Message, e.Code, e.Details) + } + + return fmt.Sprintf("%s (code: %d)", e.Message, e.Code) +} + +func (e *Error) prepare() { + if e.Status == 0 { + e.Status = errCodeStatuses[e.Code] + } + + if e.Message == "" { + e.Message = errCodeMessages[e.Code] + } +} + +// Error code constants +const ( + ErrBadRequest = 400 + ErrForbidden = 403 + ErrNotFound = 404 + ErrMethodNotAllowed = 405 + ErrTooManyRequests = 429 + ErrInternalServerError = 500 // catch-all code for unknown errors + + // Auth related + ErrInvalidToken = 1001 + ErrMissingScope = 1002 + + // Account related + ErrAccountNotFound = 1003 +) + +func ErrCodeMessage(code int) string { + return errCodeMessages[code] +} + +var errCodeMessages = map[int]string{ + ErrBadRequest: "Bad request", + ErrForbidden: "Forbidden", + ErrInternalServerError: "Internal server error", + ErrNotFound: "Not found", + ErrTooManyRequests: "Rate limit reached", + ErrMethodNotAllowed: "Method not allowed", + + ErrInvalidToken: "No token supplied, or token is invalid", + ErrMissingScope: "Token is missing required scope for this endpoint", + + ErrAccountNotFound: "Account not found", +} + +func ErrCodeStatus(code int) int { + return errCodeStatuses[code] +} + +var errCodeStatuses = map[int]int{ + ErrBadRequest: http.StatusBadRequest, + ErrForbidden: http.StatusForbidden, + ErrInternalServerError: http.StatusInternalServerError, + ErrNotFound: http.StatusNotFound, + ErrTooManyRequests: http.StatusTooManyRequests, + ErrMethodNotAllowed: http.StatusMethodNotAllowed, + + ErrInvalidToken: http.StatusUnauthorized, + ErrMissingScope: http.StatusForbidden, + + ErrAccountNotFound: http.StatusNotFound, +} diff --git a/web/app/middleware.go b/web/app/middleware.go index f55cc96..19126b9 100644 --- a/web/app/middleware.go +++ b/web/app/middleware.go @@ -3,9 +3,12 @@ package app import ( "context" "net/http" + "strings" "time" "git.sleepycat.moe/sam/mercury/internal/database" + "git.sleepycat.moe/sam/mercury/web/api" + "github.com/go-chi/render" ) type ctxKey int @@ -42,6 +45,62 @@ func (app *App) FrontendAuth(next http.Handler) http.Handler { return http.HandlerFunc(fn) } +func (app *App) APIAuth(scope database.TokenScope, anonAccess bool) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if header == "" { + cookie, err := r.Cookie(database.TokenCookieName) + if err != nil || cookie.Value == "" { + if anonAccess { // no token supplied, but the endpoint allows anonymous access + next.ServeHTTP(w, r) + return + } + + render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken)) + render.JSON(w, r, api.Error{ + Code: api.ErrInvalidToken, + Message: api.ErrCodeMessage(api.ErrInvalidToken), + }) + return + } + } + + token, err := app.ParseToken(r.Context(), header) + if err != nil { + render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken)) + render.JSON(w, r, api.Error{ + Code: api.ErrInvalidToken, + Message: api.ErrCodeMessage(api.ErrInvalidToken), + }) + return + } + + if token.Expires.Before(time.Now()) { + render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken)) + render.JSON(w, r, api.Error{ + Code: api.ErrInvalidToken, + Message: api.ErrCodeMessage(api.ErrInvalidToken), + }) + return + } + + if !token.Scopes.Has(scope) { + render.Status(r, api.ErrCodeStatus(api.ErrMissingScope)) + render.JSON(w, r, api.Error{ + Code: api.ErrMissingScope, + Message: api.ErrCodeMessage(api.ErrMissingScope), + }) + return + } + + ctx := context.WithValue(r.Context(), ctxKeyClaims, token) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + func (app *App) TokenFromContext(ctx context.Context) (database.Token, bool) { v := ctx.Value(ctxKeyClaims) if v == nil { diff --git a/web/routes.go b/web/routes.go index a383c41..853d58e 100644 --- a/web/routes.go +++ b/web/routes.go @@ -1,6 +1,9 @@ package web import ( + "git.sleepycat.moe/sam/mercury/internal/database" + "git.sleepycat.moe/sam/mercury/web/api" + "git.sleepycat.moe/sam/mercury/web/api/accounts" "git.sleepycat.moe/sam/mercury/web/app" "git.sleepycat.moe/sam/mercury/web/auth" "git.sleepycat.moe/sam/mercury/web/frontend" @@ -31,4 +34,14 @@ func Routes(app *app.App) { r.HandleFunc("/web/@{username}", frontend.ServeUser) r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus) }) + + // APIv1 handlers + app.Router.Route("/api/v1", func(r chi.Router) { + // account handlers + accounts := accounts.New(app) + r.With(app.APIAuth(database.TokenScopeAccountsRead, true)). + Get("/accounts/{accountID}", api.WrapHandlerT(accounts.GetID)) + r.With(app.APIAuth(database.TokenScopeAccountsMe, false)). + Get("/accounts/@me", api.WrapHandlerT(accounts.GetMe)) + }) }