// cctv server entrypoint package main import ( "context" "encoding/json" "fmt" "log" "log/slog" "net/http/httputil" "net/url" "os" "strings" "github.com/go-gst/go-gst/gst" "github.com/joho/godotenv" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/plugins/migratecmd" "git.koval.net/cyclane/cctv/server/config" "git.koval.net/cyclane/cctv/server/ingest" _ "git.koval.net/cyclane/cctv/server/migrations" ) var ( ingestService *ingest.Ingest ) func loadEnvFile(filenames ...string) { for _, filename := range filenames { if err := godotenv.Load(filename); err == nil { log.Printf("Loaded environment variables from %s", filename) } } } func exportCollectionsJSON(e *core.BootstrapEvent) error { if err := e.Next(); err != nil { return err } collections, err := e.App.FindAllCollections() if err != nil { return fmt.Errorf("failed to export collections: %w", err) } f, err := os.OpenFile("collections.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to open collections.json: %w", err) } defer func() { if err := f.Close(); err != nil { log.Printf("Failed to close collections.json: %v", err) } }() encoder := json.NewEncoder(f) encoder.SetIndent("", "\t") if err := encoder.Encode(collections); err != nil { return fmt.Errorf("failed to write collections to JSON: %w", err) } return nil } func main() { loadEnvFile(".env.local", ".env", ".env.default") config, err := config.Load() if err != nil { log.Fatalf("Failed to load configuration: %v", err) } app := pocketbase.NewWithConfig(pocketbase.Config{ DefaultDev: config.IsDev(), DefaultDataDir: config.DbDataDir, DefaultEncryptionEnv: config.DbEncryptionKey, }) migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ // enable auto creation of migration files when making collection changes in the Dashboard Automigrate: config.IsDev(), }) if config.IsDev() { app.OnBootstrap().BindFunc(exportCollectionsJSON) } var proxy *httputil.ReverseProxy if config.ExternalWebApp != "" { proxyURL, err := url.Parse(config.ExternalWebApp) if err != nil { log.Fatalf("Failed to parse external web app URL: %v", err) } proxy = httputil.NewSingleHostReverseProxy(proxyURL) proxy.ErrorLog = slog.NewLogLogger(app.Logger().With("svc", "proxy").Handler(), slog.LevelDebug) } ingestCtx, cancelIngestCtx := context.WithCancel(context.Background()) app.OnServe().BindFunc(func(se *core.ServeEvent) error { gst.Init(nil) ingestService = ingest.BeginIngest(ingestCtx, app) if !config.IsDev() { se.Router.BindFunc(func(e *core.RequestEvent) error { if strings.HasPrefix(e.Request.URL.Path, "/_/") { return e.NotFoundError("Page not found", nil) } else { return e.Next() } }) } if proxy == nil { // TODO: serve bundled (static) files } else { se.Router.GET("/{path...}", func(e *core.RequestEvent) error { proxy.ServeHTTP(e.Response, e.Request) return nil }) } registerAPI(se) return se.Next() }) app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { app.Logger().Info("Shutting down ingest service") cancelIngestCtx() return nil }) app.OnRecordCreateExecute().BindFunc(handleCameraUpdate) app.OnRecordUpdateExecute().BindFunc(handleCameraUpdate) if err := app.Start(); err != nil { app.Logger().Info("Application terminated with error") cancelIngestCtx() log.Fatal(err) } ingestService.Wait() }