mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Rename / namespace: - Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep across all Go sources; restore the renamed root launcher (Base, base.go). - Drop the upstream-only surfaces ours doesn't ship (admin ui/, the password/OTP/oauth2/impersonate auth files, extensions) re-added by the upstream merge. - Decomplect the on-disk layout to one base_/ namespace: data/, migrations/, hooks/, types.d.ts are siblings under base_/ (default data dir base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's identical JS/Go branches to one path. - Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and gitignore the runtime namespace dirs. Deps / build: - kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module (the root kms module zip exceeds Go's download limit); cloud/pkg/base Mount removed (lives in the fused daemon). - replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto); network/metrics.go import fix; auth gitlab/github tidy. Tests / behavior: - Wire registerNotifyWatcherHooks; debounce notify files past the kqueue rescan window so cross-instance settings/collections reload fires. - migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate Go+JS automigration goldens from canonical output. - Convert the two stale archive red-probes to assert the landed defenses (backlog cap bounds memory; forged-segment signature is rejected). IAM-native (Base = pure IAM client): - Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused daemon) is required; Base validates IAM JWTs via JWKS only — superuser is the IAM admin claim, resolved to an ephemeral unpersisted record. - Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was mis-colocated in the embedded onboarding file and is independent of IAM. - Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a JWKS stub (no hardcoded record tokens, no persisted rows).
196 lines
6.3 KiB
Go
196 lines
6.3 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
|
|
|
|
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))
|
|
|
|
// 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.
|
|
prefix := strings.TrimRight(os.Getenv("BASE_API_PREFIX"), "/")
|
|
if prefix == "" {
|
|
prefix = "/v1"
|
|
}
|
|
if !strings.HasPrefix(prefix, "/") {
|
|
prefix = "/" + prefix
|
|
}
|
|
app.Logger().Info("base: API mount prefix", "prefix", prefix)
|
|
|
|
apiGroup := baseRouter.Group(prefix)
|
|
bindSQLApi(app, apiGroup)
|
|
bindSettingsApi(app, apiGroup)
|
|
bindCollectionApi(app, apiGroup)
|
|
bindRecordCrudApi(app, apiGroup)
|
|
bindRecordAuthApi(app, apiGroup)
|
|
bindLogsApi(app, apiGroup)
|
|
bindBackupApi(app, apiGroup)
|
|
bindCronApi(app, apiGroup)
|
|
bindFileApi(app, apiGroup)
|
|
bindBatchApi(app, apiGroup)
|
|
bindTasksApi(app, apiGroup)
|
|
bindHealthApi(app, apiGroup)
|
|
bindRealtimeApi(app, apiGroup)
|
|
bindPrivateApi(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
|
|
}
|
|
|
|
// 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
|
|
}
|