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).
192 lines
4.5 KiB
Go
192 lines
4.5 KiB
Go
package apis
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
)
|
|
|
|
const (
|
|
runSQLMaxRows = 1000
|
|
runSQLMaxTimeout = 3 * time.Minute
|
|
)
|
|
|
|
// bindSQLApi registers the SQL api endpoints.
|
|
func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
|
subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
|
|
subGroup.POST("", runSQL)
|
|
}
|
|
|
|
func runSQL(e *core.RequestEvent) error {
|
|
// extra precaution in case manually invoked from somewhere else
|
|
if !e.HasSuperuserAuth() {
|
|
return e.ForbiddenError("", nil)
|
|
}
|
|
|
|
form := runSQLForm{}
|
|
|
|
err := e.BindBody(&form)
|
|
if err != nil {
|
|
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
|
}
|
|
|
|
err = form.validate()
|
|
if err != nil {
|
|
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
|
}
|
|
|
|
result, err := executeQuery(e.App, form.Query, runSQLMaxRows)
|
|
if err != nil {
|
|
return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
type runSQLForm struct {
|
|
Query string `form:"query" json:"query"`
|
|
}
|
|
|
|
func (form *runSQLForm) validate() error {
|
|
return validation.ValidateStruct(form,
|
|
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
|
|
)
|
|
}
|
|
|
|
type runSQLResultColumn struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Nullable bool `json:"nullable"`
|
|
}
|
|
|
|
type runSQLResult struct {
|
|
ExecTime int64 `json:"execTime"`
|
|
AffectedRows int64 `json:"affectedRows"`
|
|
Columns []runSQLResultColumn `json:"columns"`
|
|
Rows [][]any `json:"rows"`
|
|
}
|
|
|
|
var knownWriteQueryPrefixes = []string{"INSERT", "CREATE", "UPDATE", "DELETE", "DROP", "DETACH"}
|
|
|
|
func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
// see https://github.com/mattn/go-sqlite3/issues/950
|
|
return nil, errors.New("empty query")
|
|
}
|
|
|
|
var isPossibleWriteQuery bool
|
|
|
|
// loosely check the query type
|
|
ucQuery := strings.ToUpper(query)
|
|
if !strings.HasPrefix(ucQuery, "SELECT") {
|
|
for _, prefix := range knownWriteQueryPrefixes {
|
|
if strings.HasPrefix(ucQuery, prefix) {
|
|
isPossibleWriteQuery = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// note: don't extend the request context to minimize the risk of
|
|
// causing integrity issues with custom non-transaction mutations
|
|
ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
|
|
defer cancelFunc()
|
|
|
|
result := &runSQLResult{
|
|
// init empty slices to ensure "[]" serialization
|
|
Columns: []runSQLResultColumn{},
|
|
Rows: [][]any{},
|
|
}
|
|
|
|
now := time.Now()
|
|
defer func() {
|
|
result.ExecTime = time.Since(now).Milliseconds()
|
|
}()
|
|
|
|
// assume write/mutation query
|
|
// ---------------------------------------------------------------
|
|
if isPossibleWriteQuery {
|
|
// auto wrap in transaction in case there are multiple inline queries
|
|
txErr := app.RunInTransaction(func(txApp core.App) error {
|
|
execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result.AffectedRows, err = execResult.RowsAffected()
|
|
if err != nil {
|
|
// non-critical error (e.g. not supported by the driver)
|
|
txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
return nil, txErr
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// assume query returning rows
|
|
// ---------------------------------------------------------------
|
|
rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
// populate columns info
|
|
// ---
|
|
colTypes, err := rows.ColumnTypes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, colType := range colTypes {
|
|
col := runSQLResultColumn{
|
|
Name: colType.Name(),
|
|
Type: colType.DatabaseTypeName(),
|
|
}
|
|
col.Nullable, _ = colType.Nullable()
|
|
|
|
result.Columns = append(result.Columns, col)
|
|
}
|
|
|
|
// populate rows
|
|
// ---
|
|
for rows.Next() {
|
|
if len(result.Rows) >= maxRows {
|
|
break
|
|
}
|
|
|
|
rowData := make([]any, len(colTypes))
|
|
for i := 0; i < len(colTypes); i++ {
|
|
var v *string
|
|
rowData[i] = &v
|
|
}
|
|
|
|
err := rows.Scan(rowData...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result.Rows = append(result.Rows, rowData)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|