mirror of
https://github.com/pocketbase/pocketbase.git
synced 2024-11-21 13:35:49 +02:00
422 lines
12 KiB
Go
422 lines
12 KiB
Go
package apis
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/models"
|
|
"github.com/pocketbase/pocketbase/tokens"
|
|
"github.com/pocketbase/pocketbase/tools/list"
|
|
"github.com/pocketbase/pocketbase/tools/routine"
|
|
"github.com/pocketbase/pocketbase/tools/security"
|
|
"github.com/spf13/cast"
|
|
)
|
|
|
|
// Common request context keys used by the middlewares and api handlers.
|
|
const (
|
|
ContextAdminKey string = "admin"
|
|
ContextAuthRecordKey string = "authRecord"
|
|
ContextCollectionKey string = "collection"
|
|
ContextExecStartKey string = "execStart"
|
|
)
|
|
|
|
// RequireGuestOnly middleware requires a request to NOT have a valid
|
|
// Authorization header.
|
|
//
|
|
// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
|
|
func RequireGuestOnly() echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
err := NewBadRequestError("The request can be accessed only by guests.", nil)
|
|
|
|
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
|
if record != nil {
|
|
return err
|
|
}
|
|
|
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
|
if admin != nil {
|
|
return err
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireRecordAuth middleware requires a request to have
|
|
// a valid record auth Authorization header.
|
|
//
|
|
// The auth record could be from any collection.
|
|
//
|
|
// You can further filter the allowed record auth collections by
|
|
// specifying their names.
|
|
//
|
|
// Example:
|
|
//
|
|
// apis.RequireRecordAuth()
|
|
//
|
|
// Or:
|
|
//
|
|
// apis.RequireRecordAuth("users", "supervisors")
|
|
//
|
|
// To restrict the auth record only to the loaded context collection,
|
|
// use [apis.RequireSameContextRecordAuth()] instead.
|
|
func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
|
if record == nil {
|
|
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
|
}
|
|
|
|
// check record collection name
|
|
if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
|
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireSameContextRecordAuth middleware requires a request to have
|
|
// a valid record Authorization header.
|
|
//
|
|
// The auth record must be from the same collection already loaded in the context.
|
|
func RequireSameContextRecordAuth() echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
|
if record == nil {
|
|
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
|
}
|
|
|
|
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
|
if collection == nil || record.Collection().Id != collection.Id {
|
|
return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdminAuth middleware requires a request to have
|
|
// a valid admin Authorization header.
|
|
func RequireAdminAuth() echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
|
if admin == nil {
|
|
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdminAuthOnlyIfAny middleware requires a request to have
|
|
// a valid admin Authorization header ONLY if the application has
|
|
// at least 1 existing Admin model.
|
|
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
|
if admin != nil {
|
|
return next(c)
|
|
}
|
|
|
|
totalAdmins, err := app.Dao().TotalAdmins()
|
|
if err != nil {
|
|
return NewBadRequestError("Failed to fetch admins info.", err)
|
|
}
|
|
|
|
if totalAdmins == 0 {
|
|
return next(c)
|
|
}
|
|
|
|
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdminOrRecordAuth middleware requires a request to have
|
|
// a valid admin or record Authorization header set.
|
|
//
|
|
// You can further filter the allowed auth record collections by providing their names.
|
|
//
|
|
// This middleware is the opposite of [apis.RequireGuestOnly()].
|
|
func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
|
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
|
|
|
if admin == nil && record == nil {
|
|
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
|
}
|
|
|
|
if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
|
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdminOrOwnerAuth middleware requires a request to have
|
|
// a valid admin or auth record owner Authorization header set.
|
|
//
|
|
// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
|
|
// for the auth record token expects to have the same id as the path
|
|
// parameter ownerIdParam (default to "id" if empty).
|
|
func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
|
if admin != nil {
|
|
return next(c)
|
|
}
|
|
|
|
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
|
if record == nil {
|
|
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
|
}
|
|
|
|
if ownerIdParam == "" {
|
|
ownerIdParam = "id"
|
|
}
|
|
ownerId := c.PathParam(ownerIdParam)
|
|
|
|
// note: it is "safe" to compare only the record id since the auth
|
|
// record ids are treated as unique across all auth collections
|
|
if record.Id != ownerId {
|
|
return NewForbiddenError("You are not allowed to perform this request.", nil)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// LoadAuthContext middleware reads the Authorization request header
|
|
// and loads the token related record or admin instance into the
|
|
// request's context.
|
|
//
|
|
// This middleware is expected to be already registered by default for all routes.
|
|
func LoadAuthContext(app core.App) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
token := c.Request().Header.Get("Authorization")
|
|
if token == "" {
|
|
return next(c)
|
|
}
|
|
|
|
// the schema is not required and it is only for
|
|
// compatibility with the defaults of some HTTP clients
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
|
|
claims, _ := security.ParseUnverifiedJWT(token)
|
|
tokenType := cast.ToString(claims["type"])
|
|
|
|
switch tokenType {
|
|
case tokens.TypeAdmin:
|
|
admin, err := app.Dao().FindAdminByToken(
|
|
token,
|
|
app.Settings().AdminAuthToken.Secret,
|
|
)
|
|
if err == nil && admin != nil {
|
|
c.Set(ContextAdminKey, admin)
|
|
}
|
|
case tokens.TypeAuthRecord:
|
|
record, err := app.Dao().FindAuthRecordByToken(
|
|
token,
|
|
app.Settings().RecordAuthToken.Secret,
|
|
)
|
|
if err == nil && record != nil {
|
|
c.Set(ContextAuthRecordKey, record)
|
|
}
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// LoadCollectionContext middleware finds the collection with related
|
|
// path identifier and loads it into the request context.
|
|
//
|
|
// Set optCollectionTypes to further filter the found collection by its type.
|
|
func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if param := c.PathParam("collection"); param != "" {
|
|
collection, err := core.FindCachedCollectionByNameOrId(app, param)
|
|
if err != nil || collection == nil {
|
|
return NewNotFoundError("", err)
|
|
}
|
|
|
|
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
|
|
return NewBadRequestError("Unsupported collection type.", nil)
|
|
}
|
|
|
|
c.Set(ContextCollectionKey, collection)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ActivityLogger middleware takes care to save the request information
|
|
// into the logs database.
|
|
//
|
|
// The middleware does nothing if the app logs retention period is zero
|
|
// (aka. app.Settings().Logs.MaxDays = 0).
|
|
func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if err := next(c); err != nil {
|
|
return err
|
|
}
|
|
|
|
logRequest(app, c, nil)
|
|
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func logRequest(app core.App, c echo.Context, err *ApiError) {
|
|
// no logs retention
|
|
if app.Settings().Logs.MaxDays == 0 {
|
|
return
|
|
}
|
|
|
|
attrs := make([]any, 0, 15)
|
|
|
|
attrs = append(attrs, slog.String("type", "request"))
|
|
|
|
started := cast.ToTime(c.Get(ContextExecStartKey))
|
|
if !started.IsZero() {
|
|
attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
|
|
}
|
|
|
|
httpRequest := c.Request()
|
|
httpResponse := c.Response()
|
|
method := strings.ToUpper(httpRequest.Method)
|
|
status := httpResponse.Status
|
|
requestUri := httpRequest.URL.RequestURI()
|
|
|
|
// parse the request error
|
|
if err != nil {
|
|
status = err.Code
|
|
attrs = append(
|
|
attrs,
|
|
slog.String("error", err.Message),
|
|
slog.Any("details", err.RawData()),
|
|
)
|
|
}
|
|
|
|
requestAuth := models.RequestAuthGuest
|
|
if c.Get(ContextAuthRecordKey) != nil {
|
|
requestAuth = models.RequestAuthRecord
|
|
} else if c.Get(ContextAdminKey) != nil {
|
|
requestAuth = models.RequestAuthAdmin
|
|
}
|
|
|
|
attrs = append(
|
|
attrs,
|
|
slog.String("url", requestUri),
|
|
slog.String("method", method),
|
|
slog.Int("status", status),
|
|
slog.String("auth", requestAuth),
|
|
slog.String("referer", httpRequest.Referer()),
|
|
slog.String("userAgent", httpRequest.UserAgent()),
|
|
)
|
|
|
|
if app.Settings().Logs.LogIp {
|
|
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
|
attrs = append(
|
|
attrs,
|
|
slog.String("userIp", realUserIp(httpRequest, ip)),
|
|
slog.String("remoteIp", ip),
|
|
)
|
|
}
|
|
|
|
// don't block on logs write
|
|
routine.FireAndForget(func() {
|
|
message := method + " "
|
|
|
|
if escaped, err := url.PathUnescape(requestUri); err == nil {
|
|
message += escaped
|
|
} else {
|
|
message += requestUri
|
|
}
|
|
|
|
if err != nil {
|
|
app.Logger().Error(message, attrs...)
|
|
} else {
|
|
app.Logger().Info(message, attrs...)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
|
|
//
|
|
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
|
|
func realUserIp(r *http.Request, fallbackIp string) string {
|
|
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
|
|
if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
|
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
|
|
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
|
|
// extract the first non-empty leftmost-ish ip
|
|
ips := strings.Split(ipsList, ",")
|
|
for _, ip := range ips {
|
|
ip = strings.TrimSpace(ip)
|
|
if ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallbackIp
|
|
}
|
|
|
|
// @todo consider removing as this may no longer be needed due to the custom rest.MultiBinder.
|
|
//
|
|
// eagerRequestInfoCache ensures that the request data is cached in the request
|
|
// context to allow reading for example the json request body data more than once.
|
|
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
switch c.Request().Method {
|
|
// currently we are eagerly caching only the requests with body
|
|
case "POST", "PUT", "PATCH", "DELETE":
|
|
RequestInfo(c)
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|