Files
base/apis/base.go
zeekay 7e92f2d9e5 base: BASE_API_PREFIX + rip dead BASE_AUTH_MODE / /_/auth/oidc
One mount-prefix knob for the data plane: BASE_API_PREFIX (default
/v1). Multi-app deployments set /v1/<app> so a gateway can route by
path. Hanzo IAM is always a fixed sibling at /v1/iam — apps do not
mount their own IAM at /v1/<app>/iam.

Ripped:
- BASE_AUTH_MODE env knob — was a no-op after the IAM-native rip
  (the "builtin" branch had no routes left to bind).
- apis/superuser_auth_oidc.go (~460 lines) — stale parallel OIDC
  superuser path at /_/auth/oidc/*. The canonical path is
  /v1/iam/oauth/* via the platform plugin (proxy or embedded).
- apis/base.go EXTERNAL_AUTH_ONLY env-var fallback — the platform
  plugin is mandatory and sets the store keys directly. One way only.

installer.go: skip the first-superuser installer when the platform
plugin has flagged external-only mode (IAM owns first login).

LLM.md updated to reflect the new contract.
2026-05-15 16:03:03 -07:00

195 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)
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
}