2022-07-06 23:19:05 +02:00
|
|
|
// Package apis implements the default PocketBase api services and middlewares.
|
|
|
|
package apis
|
|
|
|
|
|
|
|
import (
|
2022-10-30 10:28:14 +02:00
|
|
|
"errors"
|
2022-07-06 23:19:05 +02:00
|
|
|
"fmt"
|
|
|
|
"io/fs"
|
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/labstack/echo/v5"
|
|
|
|
"github.com/labstack/echo/v5/middleware"
|
|
|
|
"github.com/pocketbase/pocketbase/core"
|
2023-04-25 16:58:51 +02:00
|
|
|
"github.com/pocketbase/pocketbase/tools/rest"
|
2022-07-06 23:19:05 +02:00
|
|
|
"github.com/pocketbase/pocketbase/ui"
|
2022-07-10 10:46:21 +02:00
|
|
|
"github.com/spf13/cast"
|
2022-07-06 23:19:05 +02:00
|
|
|
)
|
|
|
|
|
2022-08-01 19:37:51 +02:00
|
|
|
const trailedAdminPath = "/_/"
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// InitApi creates a configured echo instance with registered
|
|
|
|
// system and app specific routes and middlewares.
|
|
|
|
func InitApi(app core.App) (*echo.Echo, error) {
|
|
|
|
e := echo.New()
|
|
|
|
e.Debug = app.IsDebug()
|
2023-04-25 16:58:51 +02:00
|
|
|
e.JSONSerializer = &rest.Serializer{
|
|
|
|
FieldsParam: "fields",
|
|
|
|
}
|
2022-07-06 23:19:05 +02:00
|
|
|
|
2023-01-09 22:32:34 +02:00
|
|
|
// configure a custom router
|
|
|
|
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
|
|
|
return echo.NewRouter(echo.RouterConfig{
|
|
|
|
UnescapePathParamValues: true,
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// default middlewares
|
2022-08-01 19:37:51 +02:00
|
|
|
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
|
|
|
Skipper: func(c echo.Context) bool {
|
2023-03-15 18:09:16 +02:00
|
|
|
// enable by default only for the API routes
|
|
|
|
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
|
2022-08-01 19:37:51 +02:00
|
|
|
},
|
|
|
|
}))
|
2022-07-06 23:19:05 +02:00
|
|
|
e.Use(middleware.Recover())
|
|
|
|
e.Use(middleware.Secure())
|
|
|
|
e.Use(LoadAuthContext(app))
|
|
|
|
|
|
|
|
// custom error handler
|
|
|
|
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
|
|
|
if c.Response().Committed {
|
2023-03-22 15:42:35 +02:00
|
|
|
if app.IsDebug() {
|
|
|
|
log.Println("HTTPErrorHandler response was already committed:", err)
|
|
|
|
}
|
2022-07-06 23:19:05 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-10-30 10:28:14 +02:00
|
|
|
var apiErr *ApiError
|
2022-07-06 23:19:05 +02:00
|
|
|
|
|
|
|
switch v := err.(type) {
|
2022-07-10 08:13:44 +02:00
|
|
|
case *echo.HTTPError:
|
2022-07-06 23:19:05 +02:00
|
|
|
if v.Internal != nil && app.IsDebug() {
|
|
|
|
log.Println(v.Internal)
|
|
|
|
}
|
|
|
|
msg := fmt.Sprintf("%v", v.Message)
|
2022-10-30 10:28:14 +02:00
|
|
|
apiErr = NewApiError(v.Code, msg, v)
|
|
|
|
case *ApiError:
|
2022-07-06 23:19:05 +02:00
|
|
|
if app.IsDebug() && v.RawData() != nil {
|
|
|
|
log.Println(v.RawData())
|
|
|
|
}
|
|
|
|
apiErr = v
|
|
|
|
default:
|
|
|
|
if err != nil && app.IsDebug() {
|
|
|
|
log.Println(err)
|
|
|
|
}
|
2022-10-30 10:28:14 +02:00
|
|
|
apiErr = NewBadRequestError("", err)
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
|
2023-01-27 22:19:08 +02:00
|
|
|
event := new(core.ApiErrorEvent)
|
|
|
|
event.HttpContext = c
|
|
|
|
event.Error = apiErr
|
2022-07-06 23:19:05 +02:00
|
|
|
|
2022-12-03 14:50:02 +02:00
|
|
|
// send error response
|
2022-12-02 16:36:15 +02:00
|
|
|
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
2022-12-03 14:50:02 +02:00
|
|
|
// @see https://github.com/labstack/echo/issues/608
|
2022-12-02 16:36:15 +02:00
|
|
|
if e.HttpContext.Request().Method == http.MethodHead {
|
|
|
|
return e.HttpContext.NoContent(apiErr.Code)
|
|
|
|
}
|
|
|
|
|
|
|
|
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
|
|
|
})
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// truly rare case; eg. client already disconnected
|
2022-12-02 16:36:15 +02:00
|
|
|
if hookErr != nil && app.IsDebug() {
|
|
|
|
log.Println(hookErr)
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
2022-12-02 16:36:15 +02:00
|
|
|
|
|
|
|
app.OnAfterApiError().Trigger(event)
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
|
2022-07-10 10:46:21 +02:00
|
|
|
// admin ui routes
|
|
|
|
bindStaticAdminUI(app, e)
|
2022-07-06 23:19:05 +02:00
|
|
|
|
|
|
|
// default routes
|
|
|
|
api := e.Group("/api")
|
2022-10-30 10:28:14 +02:00
|
|
|
bindSettingsApi(app, api)
|
|
|
|
bindAdminApi(app, api)
|
|
|
|
bindCollectionApi(app, api)
|
|
|
|
bindRecordCrudApi(app, api)
|
|
|
|
bindRecordAuthApi(app, api)
|
|
|
|
bindFileApi(app, api)
|
|
|
|
bindRealtimeApi(app, api)
|
|
|
|
bindLogsApi(app, api)
|
2022-12-11 17:32:43 +02:00
|
|
|
bindHealthApi(app, api)
|
2022-07-06 23:19:05 +02:00
|
|
|
|
|
|
|
// trigger the custom BeforeServe hook for the created api router
|
|
|
|
// allowing users to further adjust its options or register new routes
|
|
|
|
serveEvent := &core.ServeEvent{
|
|
|
|
App: app,
|
|
|
|
Router: e,
|
|
|
|
}
|
|
|
|
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-04-15 13:44:07 +02:00
|
|
|
// note: it is after the OnBeforeServe hook to ensure that the implicit
|
|
|
|
// cache is after any user custom defined middlewares
|
|
|
|
e.Use(eagerRequestDataCache(app))
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// catch all any route
|
|
|
|
api.Any("/*", func(c echo.Context) error {
|
|
|
|
return echo.ErrNotFound
|
|
|
|
}, ActivityLogger(app))
|
|
|
|
|
|
|
|
return e, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
|
|
|
|
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
|
|
|
|
//
|
2022-10-30 10:28:14 +02:00
|
|
|
// If a file resource is missing and indexFallback is set, the request
|
|
|
|
// will be forwarded to the base index.html (useful also for SPA).
|
|
|
|
//
|
2022-07-06 23:19:05 +02:00
|
|
|
// @see https://github.com/labstack/echo/issues/2211
|
2022-10-30 10:28:14 +02:00
|
|
|
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
|
2022-07-06 23:19:05 +02:00
|
|
|
return func(c echo.Context) error {
|
|
|
|
p := c.PathParam("*")
|
2022-10-30 10:28:14 +02:00
|
|
|
|
|
|
|
// escape url path
|
|
|
|
tmpPath, err := url.PathUnescape(p)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to unescape path variable: %w", err)
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
2022-10-30 10:28:14 +02:00
|
|
|
p = tmpPath
|
2022-07-06 23:19:05 +02:00
|
|
|
|
|
|
|
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
|
|
|
|
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
|
|
|
|
|
2022-10-30 10:28:14 +02:00
|
|
|
fileErr := c.FileFS(name, fileSystem)
|
|
|
|
|
|
|
|
if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
|
|
|
|
return c.FileFS("index.html", fileSystem)
|
|
|
|
}
|
|
|
|
|
|
|
|
return fileErr
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
}
|
2022-07-10 10:46:21 +02:00
|
|
|
|
|
|
|
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
|
|
|
|
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
2022-08-01 19:37:51 +02:00
|
|
|
// redirect to trailing slash to ensure that relative urls will still work properly
|
|
|
|
e.GET(
|
|
|
|
strings.TrimRight(trailedAdminPath, "/"),
|
|
|
|
func(c echo.Context) error {
|
2023-02-24 18:49:46 +02:00
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
|
2022-08-01 19:37:51 +02:00
|
|
|
},
|
2022-07-10 10:46:21 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
// serves static files from the /ui/dist directory
|
|
|
|
// (similar to echo.StaticFS but with gzip middleware enabled)
|
|
|
|
e.GET(
|
2022-08-01 19:37:51 +02:00
|
|
|
trailedAdminPath+"*",
|
|
|
|
echo.StaticDirectoryHandler(ui.DistDirFS, false),
|
|
|
|
installerRedirect(app),
|
2023-04-21 00:19:57 +02:00
|
|
|
uiCacheControl(),
|
2022-07-10 10:46:21 +02:00
|
|
|
middleware.Gzip(),
|
|
|
|
)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
const totalAdminsCacheKey = "totalAdmins"
|
|
|
|
|
|
|
|
func updateTotalAdminsCache(app core.App) error {
|
|
|
|
total, err := app.Dao().TotalAdmins()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
app.Cache().Set(totalAdminsCacheKey, total)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-04-21 00:19:57 +02:00
|
|
|
func uiCacheControl() echo.MiddlewareFunc {
|
|
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
|
|
return func(c echo.Context) error {
|
2023-04-21 09:28:28 +02:00
|
|
|
// add default Cache-Control header for all Admin UI resources
|
|
|
|
// (ignoring the root admin path)
|
|
|
|
if c.Request().URL.Path != trailedAdminPath {
|
|
|
|
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
|
|
|
}
|
2023-04-21 00:19:57 +02:00
|
|
|
|
|
|
|
return next(c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-10 10:46:21 +02:00
|
|
|
// installerRedirect redirects the user to the installer admin UI page
|
|
|
|
// when the application needs some preliminary configurations to be done.
|
|
|
|
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
|
|
|
// keep totalAdminsCacheKey value up-to-date
|
|
|
|
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
|
|
|
|
return updateTotalAdminsCache(app)
|
|
|
|
})
|
|
|
|
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
|
|
|
|
return updateTotalAdminsCache(app)
|
|
|
|
})
|
|
|
|
|
|
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
|
|
return func(c echo.Context) error {
|
2022-08-01 19:37:51 +02:00
|
|
|
// skip redirect checks for non-root level index.html requests
|
|
|
|
path := c.Request().URL.Path
|
|
|
|
if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
|
|
|
|
return next(c)
|
|
|
|
}
|
|
|
|
|
2022-07-10 10:46:21 +02:00
|
|
|
// load into cache (if not already)
|
|
|
|
if !app.Cache().Has(totalAdminsCacheKey) {
|
|
|
|
if err := updateTotalAdminsCache(app); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
|
|
|
|
|
|
|
|
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
|
|
|
|
|
|
|
if totalAdmins == 0 && !hasInstallerParam {
|
|
|
|
// redirect to the installer page
|
2023-02-24 18:49:46 +02:00
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
|
2022-07-10 10:46:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if totalAdmins != 0 && hasInstallerParam {
|
2023-02-24 18:49:46 +02:00
|
|
|
// clear the installer param
|
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, "?")
|
2022-07-10 10:46:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return next(c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|