mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
A base collection that turns History on gets a _history row for every
create, update and delete: the collection and record ids, the action, the
writer (an actor field stamped from the save's context) and each changed
field's from and to. The row and the record commit or roll back together.
id, autodate, actor and hidden fields are left out, a value over 4 KiB is
recorded only as changed, and one writer's updates to one record within ten
minutes fold into one entry.
GET /v1/collections/{collection}/records/{id}/history reads it, newest
first, for whoever the record's view rule admits.
224 lines
7.6 KiB
Go
224 lines
7.6 KiB
Go
package apis
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
)
|
|
|
|
// StaticWildcardParam is the name of Static handler wildcard parameter.
|
|
const StaticWildcardParam = "path"
|
|
|
|
// NewRouter returns a new router instance loaded with the default app middlewares and api routes.
|
|
func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
|
baseRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
|
|
event := new(core.RequestEvent)
|
|
event.Response = w
|
|
event.Request = r
|
|
event.App = app
|
|
|
|
// The Base the process serves from, remembered before anything can move
|
|
// the request onto a tenant's. Both are Bases and until now only one of
|
|
// them had a name, so whatever asked for "the app" got whichever one the
|
|
// credential had chosen — which is the right answer for a tenant's data
|
|
// and the wrong one for a limit on the process's own front door.
|
|
event.Set(core.RequestEventKeyDeployment, app)
|
|
|
|
return event, nil
|
|
})
|
|
|
|
// register default middlewares
|
|
baseRouter.Bind(activityLogger())
|
|
baseRouter.Bind(panicRecover())
|
|
baseRouter.Bind(rateLimit())
|
|
baseRouter.Bind(loadAuthToken())
|
|
baseRouter.Bind(securityHeaders())
|
|
baseRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
|
|
|
prefix := Prefix()
|
|
app.Logger().Info("base: API mount prefix", "prefix", prefix)
|
|
|
|
apiGroup := baseRouter.Group(prefix)
|
|
bindSQLApi(app, apiGroup)
|
|
bindSettingsApi(app, apiGroup)
|
|
bindCollectionApi(app, apiGroup)
|
|
bindModuleApi(app, apiGroup)
|
|
bindRecordCrudApi(app, apiGroup)
|
|
bindRecordHistoryApi(app, apiGroup)
|
|
bindRecordAuthApi(app, apiGroup)
|
|
bindLogsApi(app, apiGroup)
|
|
bindBackupApi(app, apiGroup)
|
|
bindCronApi(app, apiGroup)
|
|
bindFileApi(app, apiGroup)
|
|
bindBatchApi(app, apiGroup)
|
|
bindTasksApi(app, apiGroup)
|
|
bindDatabaseApi(app, apiGroup)
|
|
bindHealthApi(app, apiGroup)
|
|
bindRealtimeApi(app, apiGroup)
|
|
bindFunctionsApi(app, apiGroup)
|
|
bindPrivateApi(app, apiGroup)
|
|
|
|
// The table wire, under the api prefix like everything else: `<prefix>/rest/
|
|
// {collection}`. It used to bind on the ROOT router at `/rest/v1` so a REST
|
|
// client pointed at a bare hostname would find it unconfigured — one client's
|
|
// convenience, paid for by a second address scheme in a fleet where every
|
|
// other address begins `/v1`. The clients are ours and can be told where the
|
|
// API is rooted.
|
|
bindRestApi(app, apiGroup)
|
|
|
|
// Platform-standard health check at root level (outside the mount
|
|
// prefix so liveness probes don't have to know the app name).
|
|
BindHealthzRoute(baseRouter)
|
|
|
|
return baseRouter, nil
|
|
}
|
|
|
|
// Prefix is where this app mounts its API — one mount prefix per app, the
|
|
// only knob. Default `/v1`. Multi-app deployments set BASE_API_PREFIX=/v1/<app>
|
|
// so the gateway can route to the right service. Hanzo IAM is a sibling at
|
|
// `/v1/iam`, mounted by the platform plugin regardless of this prefix.
|
|
//
|
|
// It reads the environment on every call rather than remembering an answer,
|
|
// because a remembered one is set in one process and empty in the next.
|
|
func Prefix() string {
|
|
prefix := strings.TrimRight(os.Getenv("BASE_API_PREFIX"), "/")
|
|
if prefix == "" {
|
|
prefix = "/v1"
|
|
}
|
|
if !strings.HasPrefix(prefix, "/") {
|
|
prefix = "/" + prefix
|
|
}
|
|
|
|
return prefix
|
|
}
|
|
|
|
// WrapStdHandler wraps Go [http.Handler] into a Base handler func.
|
|
func WrapStdHandler(h http.Handler) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
h.ServeHTTP(e.Response, e.Request)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WrapStdMiddleware wraps Go [func(http.Handler) http.Handle] into a Base middleware func.
|
|
func WrapStdMiddleware(m func(http.Handler) http.Handler) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) (err error) {
|
|
m(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
e.Response = w
|
|
e.Request = r
|
|
err = e.Next()
|
|
})).ServeHTTP(e.Response, e.Request)
|
|
return err
|
|
}
|
|
}
|
|
|
|
// MustSubFS returns an [fs.FS] corresponding to the subtree rooted at fsys's dir.
|
|
//
|
|
// This is similar to [fs.Sub] but panics on failure.
|
|
func MustSubFS(fsys fs.FS, dir string) fs.FS {
|
|
dir = filepath.ToSlash(filepath.Clean(dir)) // ToSlash in case of Windows path
|
|
|
|
sub, err := fs.Sub(fsys, dir)
|
|
if err != nil {
|
|
panic(fmt.Errorf("failed to create sub FS: %w", err))
|
|
}
|
|
|
|
return sub
|
|
}
|
|
|
|
// Static is a handler function to serve static directory content from fsys.
|
|
//
|
|
// If a file resource is missing and indexFallback is set, the request
|
|
// will be forwarded to the base index.html (useful for SPA with pretty urls).
|
|
//
|
|
// NB! Expects the route to have a "{path...}" wildcard parameter.
|
|
//
|
|
// Special redirects:
|
|
// - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
|
|
// - if "path" is a directory that has index.html, the index.html file is rendered,
|
|
// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is set
|
|
//
|
|
// Example:
|
|
//
|
|
// fsys := os.DirFS("./public")
|
|
// router.GET("/files/{path...}", apis.Static(fsys, false))
|
|
func Static(fsys fs.FS, indexFallback bool) func(*core.RequestEvent) error {
|
|
if fsys == nil {
|
|
panic("Static: the provided fs.FS argument is nil")
|
|
}
|
|
|
|
return func(e *core.RequestEvent) error {
|
|
// disable the activity logger to avoid flooding with messages
|
|
//
|
|
// note: errors are still logged
|
|
if e.Get(requestEventKeySkipSuccessActivityLog) == nil {
|
|
e.Set(requestEventKeySkipSuccessActivityLog, true)
|
|
}
|
|
|
|
filename := e.Request.PathValue(StaticWildcardParam)
|
|
filename = filepath.ToSlash(filepath.Clean(strings.TrimPrefix(filename, "/")))
|
|
|
|
// eagerly check for directory traversal
|
|
//
|
|
// note: this is just out of an abundance of caution because the fs.FS implementation could be non-std,
|
|
// but usually shouldn't be necessary since os.DirFS.Open is expected to fail if the filename starts with dots
|
|
if len(filename) > 2 && filename[0] == '.' && filename[1] == '.' && (filename[2] == '/' || filename[2] == '\\') {
|
|
if indexFallback && filename != router.IndexPage {
|
|
return e.FileFS(fsys, router.IndexPage)
|
|
}
|
|
return router.ErrFileNotFound
|
|
}
|
|
|
|
fi, err := fs.Stat(fsys, filename)
|
|
if err != nil {
|
|
if indexFallback && filename != router.IndexPage {
|
|
return e.FileFS(fsys, router.IndexPage)
|
|
}
|
|
return router.ErrFileNotFound
|
|
}
|
|
|
|
if fi.IsDir() {
|
|
// redirect to a canonical dir url, aka. with trailing slash
|
|
if !strings.HasSuffix(e.Request.URL.Path, "/") {
|
|
return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(e.Request.URL.Path+"/"))
|
|
}
|
|
} else {
|
|
urlPath := e.Request.URL.Path
|
|
if strings.HasSuffix(urlPath, "/") {
|
|
// redirect to a non-trailing slash file route
|
|
urlPath = strings.TrimRight(urlPath, "/")
|
|
if len(urlPath) > 0 {
|
|
return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(urlPath))
|
|
}
|
|
} else if stripped, ok := strings.CutSuffix(urlPath, router.IndexPage); ok {
|
|
// redirect without the index.html
|
|
return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(stripped))
|
|
}
|
|
}
|
|
|
|
fileErr := e.FileFS(fsys, filename)
|
|
|
|
if fileErr != nil && indexFallback && filename != router.IndexPage && errors.Is(fileErr, router.ErrFileNotFound) {
|
|
return e.FileFS(fsys, router.IndexPage)
|
|
}
|
|
|
|
return fileErr
|
|
}
|
|
}
|
|
|
|
// safeRedirectPath normalizes the path string by replacing all beginning slashes
|
|
// (`\\`, `//`, `\/`) with a single forward slash to prevent open redirect attacks
|
|
func safeRedirectPath(path string) string {
|
|
if len(path) > 1 && (path[0] == '\\' || path[0] == '/') && (path[1] == '\\' || path[1] == '/') {
|
|
path = "/" + strings.TrimLeft(path, `/\`)
|
|
}
|
|
return path
|
|
}
|