diff --git a/datamanager/backend/apis/base.go b/datamanager/backend/apis/base.go new file mode 100644 index 0000000..2355049 --- /dev/null +++ b/datamanager/backend/apis/base.go @@ -0,0 +1,52 @@ +package apis + +import ( + "datamanager/backend/core" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "github.com/pocketbase/pocketbase/tools/rest" + "strings" +) + +const fieldsQueryParam = "fields" + +func InitApi(app core.App) (*echo.Echo, error) { + e := echo.New() + e.Debug = false + e.Binder = &rest.MultiBinder{} + e.JSONSerializer = &rest.Serializer{ + FieldsParam: fieldsQueryParam, + } + + // configure a custom router + e.ResetRouterCreator(func(ec *echo.Echo) echo.Router { + return echo.NewRouter(echo.RouterConfig{ + UnescapePathParamValues: true, + AllowOverwritingRoute: true, + }) + }) + + // default middlewares + e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{ + Skipper: func(c echo.Context) bool { + // enable by default only for the API routes + return !strings.HasPrefix(c.Request().URL.Path, "/api/") + }, + })) + e.Use(middleware.Recover()) + e.Use(middleware.Secure()) + + // custom error handler + e.HTTPErrorHandler = func(c echo.Context, err error) { + if err == nil { + return // no error + } + + if c.Response().Committed { + return // already committed + } + + } + + return e, nil +} diff --git a/datamanager/backend/apis/serve.go b/datamanager/backend/apis/serve.go new file mode 100644 index 0000000..0608d97 --- /dev/null +++ b/datamanager/backend/apis/serve.go @@ -0,0 +1,180 @@ +package apis + +import ( + "context" + "crypto/tls" + "datamanager/backend/core" + "github.com/fatih/color" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "github.com/pocketbase/pocketbase/tools/list" + "golang.org/x/crypto/acme" + "log" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// ServeConfig defines a configuration struct for apis.Serve(). +type ServeConfig struct { + // ShowStartBanner indicates whether to show or hide the server start console message. + ShowStartBanner bool + + // HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`). + HttpAddr string + + // HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`). + HttpsAddr string + + // Optional domains list to use when issuing the TLS certificate. + // + // If not set, the host from the bound server address will be used. + // + // For convenience, for each "non-www" domain a "www" entry and + // redirect will be automatically added. + CertificateDomains []string + + // AllowedOrigins is an optional list of CORS origins (default to "*"). + AllowedOrigins []string +} + +func Serve(app core.App, config ServeConfig) (*http.Server, error) { + if len(config.AllowedOrigins) == 0 { + config.AllowedOrigins = []string{"*"} + } + + router, err := InitApi(app) + if err != nil { + return nil, err + } + + // configure cors + router.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + Skipper: middleware.DefaultSkipper, + AllowOrigins: config.AllowedOrigins, + AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, + })) + + // start http server + // --- + mainAddr := config.HttpAddr + if config.HttpsAddr != "" { + mainAddr = config.HttpsAddr + } + + var wwwRedirects []string + + // extract the host names for the certificate host policy + hostNames := config.CertificateDomains + if len(hostNames) == 0 { + host, _, _ := net.SplitHostPort(mainAddr) + hostNames = append(hostNames, host) + } + for _, host := range hostNames { + if strings.HasPrefix(host, "www.") { + continue // explicitly set www host + } + + wwwHost := "www." + host + if !list.ExistInSlice(wwwHost, hostNames) { + hostNames = append(hostNames, wwwHost) + wwwRedirects = append(wwwRedirects, wwwHost) + } + } + + // implicit www->non-www redirect(s) + if len(wwwRedirects) > 0 { + router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + host := c.Request().Host + + if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) { + return c.Redirect( + http.StatusTemporaryRedirect, + c.Scheme()+"://"+host[4:]+c.Request().RequestURI, + ) + } + + return next(c) + } + }) + } + + // base request context used for cancelling long running requests + // like the SSE connections + baseCtx, cancelBaseCtx := context.WithCancel(context.Background()) + defer cancelBaseCtx() + + server := &http.Server{ + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + NextProtos: []string{acme.ALPNProto}, + }, + ReadTimeout: 10 * time.Minute, + ReadHeaderTimeout: 30 * time.Second, + // WriteTimeout: 60 * time.Second, // breaks sse! + Handler: router, + Addr: mainAddr, + BaseContext: func(l net.Listener) context.Context { + return baseCtx + }, + } + + if config.ShowStartBanner { + schema := "http" + addr := server.Addr + + if config.HttpsAddr != "" { + schema = "https" + + if len(config.CertificateDomains) > 0 { + addr = config.CertificateDomains[0] + } + } + + date := new(strings.Builder) + log.New(date, "", log.LstdFlags).Print() + + bold := color.New(color.Bold).Add(color.FgGreen) + _, _ = bold.Printf( + "%s Server started at %s\n", + strings.TrimSpace(date.String()), + color.CyanString("%s://%s", schema, addr), + ) + } + + // WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately. + // Note that the WaitGroup would not do anything if the app.OnTerminate() hook isn't triggered. + var wg sync.WaitGroup + + // wait for the graceful shutdown to complete before exit + defer wg.Wait() + + // --- + // @todo consider removing the server return value because it is + // not really useful when combined with the blocking serve calls + // --- + + // start HTTPS server + if config.HttpsAddr != "" { + // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version + if config.HttpAddr != "" { + go func() { + err := http.ListenAndServe(config.HttpAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently) + })) + if err != nil { + slog.Error("Failed to start HTTP server for redirect: %v", err) + } + }() + } + + return server, server.ListenAndServeTLS("", "") + } + + // OR start HTTP server + return server, server.ListenAndServe() +} diff --git a/datamanager/backend/backend.go b/datamanager/backend/backend.go new file mode 100644 index 0000000..d2d9327 --- /dev/null +++ b/datamanager/backend/backend.go @@ -0,0 +1,188 @@ +package backend + +import ( + "datamanager/backend/cmd" + "datamanager/backend/core" + "github.com/fatih/color" + "github.com/spf13/cobra" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +var _ core.App = (*Backend)(nil) + +// Version of Backend +var Version = "(untracked)" + +type appWrapper struct { + core.App +} + +type Backend struct { + *appWrapper + + devFlag bool + dataDirFlag string + encryptionEnvFlag string + hideStartBanner bool + + // RootCmd is the main console command + RootCmd *cobra.Command +} + +func New() *Backend { + _, isUsingGoRun := inspectRuntime() + + return NewWithConfig(Config{ + DefaultDev: isUsingGoRun, + DBUsername: "neo4j", + DBPassword: "htwkalender-db", + }) +} + +// inspectRuntime tries to find the base executable directory and how it was run. +func inspectRuntime() (baseDir string, withGoRun bool) { + if strings.HasPrefix(os.Args[0], os.TempDir()) { + // probably ran with go run + withGoRun = true + baseDir, _ = os.Getwd() + } else { + // probably ran with go build + withGoRun = false + baseDir = filepath.Dir(os.Args[0]) + } + return +} + +type Config struct { + // optional default values for the console flags + DefaultDev bool + DefaultDataDir string // if not set, it will fallback to "./pb_data" + DBUsername string + DBPassword string +} + +func NewWithConfig(config Config) *Backend { + // initialize a default data directory based on the executable baseDir + if config.DefaultDataDir == "" { + baseDir, _ := inspectRuntime() + config.DefaultDataDir = filepath.Join(baseDir, "data") + } + + backend := &Backend{ + RootCmd: &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Short: "Backend CLI", + Version: Version, + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, + // no need to provide the default cobra completion command + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + }, + devFlag: config.DefaultDev, + dataDirFlag: config.DefaultDataDir, + } + + // replace with a colored stderr writer + backend.RootCmd.SetErr(newErrWriter()) + + // parse base flags + // (errors are ignored, since the full flags parsing happens on Execute()) + _ = backend.eagerParseFlags(&config) + + // initialize the app instance + backend.appWrapper = &appWrapper{core.NewBaseApp(core.BaseAppConfig{ + IsDev: backend.devFlag, + DataDir: backend.dataDirFlag, + DBUsername: config.DBUsername, + DBPassword: config.DBPassword, + })} + + // hide the default help command (allow only `--help` flag) + backend.RootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + return backend +} + +func newErrWriter() *coloredWriter { + return &coloredWriter{ + w: os.Stderr, + c: color.New(color.FgRed), + } +} + +// coloredWriter is a small wrapper struct to construct a [color.Color] writter. +type coloredWriter struct { + w io.Writer + c *color.Color +} + +// Write writes the p bytes using the colored writer. +func (colored *coloredWriter) Write(p []byte) (n int, err error) { + colored.c.SetWriter(colored.w) + defer colored.c.UnsetWriter(colored.w) + + return colored.c.Print(string(p)) +} + +func (backend *Backend) eagerParseFlags(config *Config) error { + backend.RootCmd.PersistentFlags().StringVar( + &backend.dataDirFlag, + "dir", + config.DefaultDataDir, + "the PocketBase data directory", + ) + + backend.RootCmd.PersistentFlags().BoolVar( + &backend.devFlag, + "dev", + config.DefaultDev, + "enable dev mode, aka. printing logs and cypher/sql statements to the console", + ) + + return backend.RootCmd.ParseFlags(os.Args[1:]) +} + +func (backend *Backend) Start() error { + // register system commands + backend.RootCmd.AddCommand(cmd.NewServeCommand(backend, !backend.hideStartBanner)) + + return backend.Execute() +} + +func (backend *Backend) Execute() error { + done := make(chan bool, 1) + + // listen for interrupt signal to gracefully shutdown the application + go func() { + sigch := make(chan os.Signal, 1) + signal.Notify(sigch, os.Interrupt, syscall.SIGTERM) + <-sigch + + done <- true + }() + + // execute the root command + go func() { + // note: leave to the commands to decide whether to print their error + _ = backend.RootCmd.Execute() + + done <- true + }() + + <-done + + // trigger cleanups + return backend.OnTerminate().Trigger(&core.TerminateEvent{ + App: backend, + }, func(e *core.TerminateEvent) error { + return e.App.ResetBootstrapState() + }) +} diff --git a/datamanager/backend/cmd/serve.go b/datamanager/backend/cmd/serve.go new file mode 100644 index 0000000..f673dbf --- /dev/null +++ b/datamanager/backend/cmd/serve.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "datamanager/backend/apis" + "datamanager/backend/core" + "errors" + "github.com/spf13/cobra" + "net/http" +) + +// NewServeCommand creates and returns new command responsible for +// starting the default PocketBase web server. +func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { + var allowedOrigins []string + var httpAddr string + var httpsAddr string + + command := &cobra.Command{ + Use: "serve [domain(s)]", + Args: cobra.ArbitraryArgs, + Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)", + SilenceUsage: true, + RunE: func(command *cobra.Command, args []string) error { + // set default listener addresses if at least one domain is specified + if len(args) > 0 { + if httpAddr == "" { + httpAddr = "0.0.0.0:80" + } + if httpsAddr == "" { + httpsAddr = "0.0.0.0:443" + } + } else { + if httpAddr == "" { + httpAddr = "127.0.0.1:8090" + } + } + + _, err := apis.Serve(app, apis.ServeConfig{ + HttpAddr: httpAddr, + HttpsAddr: httpsAddr, + ShowStartBanner: showStartBanner, + AllowedOrigins: allowedOrigins, + CertificateDomains: args, + }) + + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err + }, + } + + command.PersistentFlags().StringSliceVar( + &allowedOrigins, + "origins", + []string{"*"}, + "CORS allowed domain origins list", + ) + + command.PersistentFlags().StringVar( + &httpAddr, + "http", + "", + "TCP address to listen for the HTTP server\n(if domain args are specified - default to 0.0.0.0:80, otherwise - default to 127.0.0.1:8090)", + ) + + command.PersistentFlags().StringVar( + &httpsAddr, + "https", + "", + "TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version", + ) + + return command +} diff --git a/datamanager/backend/core/app.go b/datamanager/backend/core/app.go new file mode 100644 index 0000000..5d6be01 --- /dev/null +++ b/datamanager/backend/core/app.go @@ -0,0 +1,55 @@ +package core + +import ( + "datamanager/backend/dbx" + "datamanager/backend/tools/hook" +) + +type App interface { + // DB returns the default app database instance. + DB() *dbx.DB + OnTerminate() *hook.Hook[*TerminateEvent] + ResetBootstrapState() error +} + +func NewBaseApp(config BaseAppConfig) App { + + app := &BaseApp{ + isDev: config.IsDev, + dataDir: config.DataDir, + db: *dbx.NewDB("neo4j", "password"), + onTerminate: &hook.Hook[*TerminateEvent]{}, + } + return app +} + +type BaseApp struct { + // configurable parameters + isDev bool + dataDir string + db dbx.DB + onTerminate *hook.Hook[*TerminateEvent] +} + +func (app *BaseApp) DB() *dbx.DB { + db := app.db + + return &db +} + +func (app *BaseApp) ResetBootstrapState() error { + + if app.DB() != nil { + if err := app.db.Close(); err != nil { + return err + } + } + + // replace the db instance with nil + app.db = dbx.DB{} + return nil +} + +func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] { + return app.onTerminate +} diff --git a/datamanager/backend/core/base.go b/datamanager/backend/core/base.go new file mode 100644 index 0000000..9052ce7 --- /dev/null +++ b/datamanager/backend/core/base.go @@ -0,0 +1,8 @@ +package core + +type BaseAppConfig struct { + IsDev bool + DataDir string + DBUsername string + DBPassword string +} diff --git a/datamanager/backend/core/events.go b/datamanager/backend/core/events.go new file mode 100644 index 0000000..353bd58 --- /dev/null +++ b/datamanager/backend/core/events.go @@ -0,0 +1,6 @@ +package core + +type TerminateEvent struct { + App App + IsRestart bool +} diff --git a/datamanager/backend/dbx/db.go b/datamanager/backend/dbx/db.go new file mode 100644 index 0000000..125c8c0 --- /dev/null +++ b/datamanager/backend/dbx/db.go @@ -0,0 +1,90 @@ +package dbx + +import ( + "context" + "database/sql" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" + "log/slog" +) + +type ( + DB struct { + driver neo4j.DriverWithContext + ctx context.Context + } + + // Errors represents a list of errors. + Errors []error +) + +func NewDB(username string, password string) *DB { + driver, err := neo4j.NewDriverWithContext("bolt://localhost:7687", neo4j.BasicAuth(username, password, "")) + if err != nil { + slog.Error("Failed to create a new driver: %v", err) + return nil + } + + return &DB{ + driver: driver, + ctx: context.Background(), + } +} + +// Context returns the context associated with the DB instance. +// It returns nil if no context is associated. +func (db *DB) Context() context.Context { + return db.ctx +} + +// Close closes the database, releasing any open resources. +func (db *DB) Close() error { + if db.driver != nil { + return db.driver.Close(db.ctx) + } + return nil + +} + +// Executor prepares, executes, or queries a SQL statement. +type Executor interface { + Query(query string, args ...interface{}) (*neo4j.ResultWithContext, error) + // QueryContext queries a SQL statement with the given context + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) + // Prepare creates a prepared statement + Prepare(query string) (*sql.Stmt, error) +} + +// Query represents a neo4j query that can be executed. +type Query struct { + Executor Executor + cypher, rawCypher string +} + +func helloWorld(ctx context.Context, uri, username, password string) (string, error) { + driver, err := neo4j.NewDriverWithContext(uri, neo4j.BasicAuth(username, password, "")) + if err != nil { + return "", err + } + + session := driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite}) + + greeting, err := session.ExecuteWrite(ctx, func(transaction neo4j.ManagedTransaction) (any, error) { + result, err := transaction.Run(ctx, + "CREATE (a:Greeting) SET a.message = $message RETURN a.message + ', from node ' + id(a)", + map[string]any{"message": "hello, world"}) + if err != nil { + return nil, err + } + + if result.Next(ctx) { + return result.Record().Values[0], nil + } + + return nil, result.Err() + }) + if err != nil { + return "", err + } + + return greeting.(string), nil +} diff --git a/datamanager/backend/tools/hook/hook.go b/datamanager/backend/tools/hook/hook.go new file mode 100644 index 0000000..96c2a5b --- /dev/null +++ b/datamanager/backend/tools/hook/hook.go @@ -0,0 +1,59 @@ +package hook + +import ( + "datamanager/backend/tools/security" + "errors" + "fmt" + "sync" +) + +type Handler[T any] func(e T) error +type handlerPair[T any] struct { + id string + handler Handler[T] +} + +type Hook[T any] struct { + mux sync.RWMutex + handlers []*handlerPair[T] +} + +var StopPropagation = errors.New("event hook propagation stopped") + +func (h *Hook[T]) Trigger(data T, oneOffHandlers ...Handler[T]) error { + h.mux.RLock() + + handlers := make([]*handlerPair[T], 0, len(h.handlers)+len(oneOffHandlers)) + handlers = append(handlers, h.handlers...) + + // append the one off handlers + for i, oneOff := range oneOffHandlers { + handlers = append(handlers, &handlerPair[T]{ + id: fmt.Sprintf("@%d", i), + handler: oneOff, + }) + } + + // unlock is not deferred to avoid deadlocks in case Trigger + // is called recursively by the handlers + h.mux.RUnlock() + + for _, item := range handlers { + err := item.handler(data) + if err == nil { + continue + } + + if errors.Is(err, StopPropagation) { + return nil + } + + return err + } + + return nil +} + +func generateHookId() string { + return security.PseudorandomString(8) +} diff --git a/datamanager/backend/tools/list/list.go b/datamanager/backend/tools/list/list.go new file mode 100644 index 0000000..6fa9c4f --- /dev/null +++ b/datamanager/backend/tools/list/list.go @@ -0,0 +1,11 @@ +package list + +func ExistInSlice[T comparable](item T, list []T) bool { + for _, v := range list { + if v == item { + return true + } + } + + return false +} diff --git a/datamanager/main.go b/datamanager/main.go new file mode 100644 index 0000000..d8fa9b5 --- /dev/null +++ b/datamanager/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "datamanager/backend" + "datamanager/service" + "log/slog" +) + +func main() { + + app := backend.New() + + service.AddRoutes(app) + + if err := app.Start(); err != nil { + slog.Error("Failed to start app: %v", err) + } +}