diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 6627cbb8..4cf21bdb 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -1,20 +1,12 @@ package main import ( - "bytes" "context" "errors" "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/google/uuid" - "github.com/sysadminsmedia/homebox/backend/pkgs/utils" - "github.com/pressly/goose/v3" + "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -30,7 +22,6 @@ import ( "github.com/sysadminsmedia/homebox/backend/internal/data/ent" "github.com/sysadminsmedia/homebox/backend/internal/data/migrations" "github.com/sysadminsmedia/homebox/backend/internal/data/repo" - "github.com/sysadminsmedia/homebox/backend/internal/sys/analytics" "github.com/sysadminsmedia/homebox/backend/internal/sys/config" "github.com/sysadminsmedia/homebox/backend/internal/web/mid" "go.balki.me/anyhttp" @@ -40,7 +31,6 @@ import ( _ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/sqlite3" _ "github.com/sysadminsmedia/homebox/backend/pkgs/cgofreesqlite" - "gocloud.dev/pubsub" _ "gocloud.dev/pubsub/awssnssqs" _ "gocloud.dev/pubsub/azuresb" _ "gocloud.dev/pubsub/gcppubsub" @@ -105,41 +95,14 @@ func main() { } } -//nolint:gocyclo func run(cfg *config.Config) error { app := new(cfg) app.setupLogger() - if cfg.Options.AllowAnalytics { - analytics.Send(version, build()) - } - // ========================================================================= // Initialize Database & Repos - if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") { - raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./") - clean := filepath.Clean(raw) - absBase, err := filepath.Abs(clean) - if err != nil { - log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string") - } - // Construct and validate the full storage path - storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath) - // Set windows paths to use forward slashes required by go-cloud - storageDir = strings.ReplaceAll(storageDir, "\\", "/") - if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase { - log.Fatal(). - Str("path", storageDir). - Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path") - } - // Create with more restrictive permissions - if err := os.MkdirAll(storageDir, 0o750); err != nil { - log.Fatal(). - Err(err). - Msg("failed to create data directory") - } - } + setupStorageDir(cfg) if strings.ToLower(cfg.Database.Driver) == "postgres" { if !validatePostgresSSLMode(cfg.Database.SslMode) { @@ -147,23 +110,7 @@ func run(cfg *config.Config) error { } } - // Set up the database URL based on the driver because for some reason a common URL format is not used - databaseURL := "" - switch strings.ToLower(cfg.Database.Driver) { - case "sqlite3": - databaseURL = cfg.Database.SqlitePath - - // Create directory for SQLite database if it doesn't exist - dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0] // Remove query parameters - dbDir := filepath.Dir(dbFilePath) - if err := os.MkdirAll(dbDir, 0o755); err != nil { - log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory") - } - case "postgres": - databaseURL = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Username, cfg.Database.Password, cfg.Database.Database, cfg.Database.SslMode) - default: - log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver") - } + databaseURL := setupDatabaseURL(cfg) c, err := ent.Open(strings.ToLower(cfg.Database.Driver), databaseURL) if err != nil { @@ -189,25 +136,9 @@ func run(cfg *config.Config) error { return err } - collectFuncs := []currencies.CollectorFunc{ - currencies.CollectDefaults(), - } - - if cfg.Options.CurrencyConfig != "" { - log.Info(). - Str("path", cfg.Options.CurrencyConfig). - Msg("loading currency config file") - - content, err := os.ReadFile(cfg.Options.CurrencyConfig) - if err != nil { - log.Error(). - Err(err). - Str("path", cfg.Options.CurrencyConfig). - Msg("failed to read currency config file") - return err - } - - collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content))) + collectFuncs, err := loadCurrencies(cfg) + if err != nil { + return err } currencies, err := currencies.CollectionCurrencies(collectFuncs...) @@ -282,155 +213,8 @@ func run(cfg *config.Config) error { return httpserver.ListenAndServe() }) - // ========================================================================= // Start Reoccurring Tasks - - runner.AddFunc("eventbus", app.bus.Run) - - runner.AddFunc("seed_database", func(ctx context.Context) error { - // TODO: Remove through external API that does setup - if cfg.Demo { - log.Info().Msg("Running in demo mode, creating demo data") - err := app.SetupDemo() - if err != nil { - log.Fatal().Msg(err.Error()) - } - } - return nil - }) - - runner.AddPlugin(NewTask("purge-tokens", time.Duration(24)*time.Hour, func(ctx context.Context) { - _, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx) - if err != nil { - log.Error(). - Err(err). - Msg("failed to purge expired tokens") - } - })) - - runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) { - _, err := app.repos.Groups.InvitationPurge(ctx) - if err != nil { - log.Error(). - Err(err). - Msg("failed to purge expired invitations") - } - })) - - runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) { - now := time.Now() - - if now.Hour() == 8 { - fmt.Println("run notifiers") - err := app.services.BackgroundService.SendNotifiersToday(context.Background()) - if err != nil { - log.Error(). - Err(err). - Msg("failed to send notifiers") - } - } - })) - - go runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error { - pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails") - if err != nil { - log.Error().Err(err).Msg("failed to generate pubsub connection string") - return err - } - topic, err := pubsub.OpenTopic(ctx, pubsubString) - if err != nil { - return err - } - defer func(topic *pubsub.Topic, ctx context.Context) { - err := topic.Shutdown(ctx) - if err != nil { - log.Err(err).Msg("fail to shutdown pubsub topic") - } - }(topic, ctx) - - subscription, err := pubsub.OpenSubscription(ctx, pubsubString) - if err != nil { - log.Err(err).Msg("failed to open pubsub topic") - return err - } - defer func(topic *pubsub.Subscription, ctx context.Context) { - err := topic.Shutdown(ctx) - if err != nil { - log.Err(err).Msg("fail to shutdown pubsub topic") - } - }(subscription, ctx) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - msg, err := subscription.Receive(ctx) - log.Debug().Msg("received thumbnail generation request from pubsub topic") - if err != nil { - log.Err(err).Msg("failed to receive message from pubsub topic") - continue - } - if msg == nil { - log.Warn().Msg("received nil message from pubsub topic") - continue - } - groupId, err := uuid.Parse(msg.Metadata["group_id"]) - if err != nil { - log.Error(). - Err(err). - Str("group_id", msg.Metadata["group_id"]). - Msg("failed to parse group ID from message metadata") - } - attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"]) - if err != nil { - log.Error(). - Err(err). - Str("attachment_id", msg.Metadata["attachment_id"]). - Msg("failed to parse attachment ID from message metadata") - } - err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"]) - if err != nil { - log.Err(err).Msg("failed to create thumbnail") - } - msg.Ack() - } - } - }) - - if cfg.Options.GithubReleaseCheck { - runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) { - log.Debug().Msg("running get latest github release") - err := app.services.BackgroundService.GetLatestGithubRelease(context.Background()) - if err != nil { - log.Error(). - Err(err). - Msg("failed to get latest github release") - } - })) - } - - if cfg.Debug.Enabled { - runner.AddFunc("debug", func(ctx context.Context) error { - debugserver := http.Server{ - Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port), - Handler: app.debugRouter(), - ReadTimeout: cfg.Web.ReadTimeout, - WriteTimeout: cfg.Web.WriteTimeout, - IdleTimeout: cfg.Web.IdleTimeout, - } - - go func() { - <-ctx.Done() - _ = debugserver.Shutdown(context.Background()) - }() - - log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port) - return debugserver.ListenAndServe() - }) - // Print the configuration to the console - cfg.Print() - } + registerRecurringTasks(app, cfg, runner) return runner.Start(context.Background()) } diff --git a/backend/app/api/recurring.go b/backend/app/api/recurring.go new file mode 100644 index 00000000..986618f0 --- /dev/null +++ b/backend/app/api/recurring.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "fmt" + "github.com/hay-kot/httpkit/graceful" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/pkgs/utils" + "gocloud.dev/pubsub" +) + +func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runner) { + runner.AddFunc("eventbus", app.bus.Run) + + runner.AddFunc("seed_database", func(ctx context.Context) error { + if cfg.Demo { + log.Info().Msg("Running in demo mode, creating demo data") + err := app.SetupDemo() + if err != nil { + log.Fatal().Msg(err.Error()) + } + } + return nil + }) + + runner.AddPlugin(NewTask("purge-tokens", 24*time.Hour, func(ctx context.Context) { + _, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx) + if err != nil { + log.Error().Err(err).Msg("failed to purge expired tokens") + } + })) + + runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) { + _, err := app.repos.Groups.InvitationPurge(ctx) + if err != nil { + log.Error().Err(err).Msg("failed to purge expired invitations") + } + })) + + runner.AddPlugin(NewTask("send-notifications", time.Hour, func(ctx context.Context) { + now := time.Now() + if now.Hour() == 8 { + fmt.Println("run notifiers") + err := app.services.BackgroundService.SendNotifiersToday(context.Background()) + if err != nil { + log.Error().Err(err).Msg("failed to send notifiers") + } + } + })) + + runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error { + pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails") + if err != nil { + log.Error().Err(err).Msg("failed to generate pubsub connection string") + return err + } + topic, err := pubsub.OpenTopic(ctx, pubsubString) + if err != nil { + return err + } + defer func(topic *pubsub.Topic, ctx context.Context) { + err := topic.Shutdown(ctx) + if err != nil { + log.Err(err).Msg("fail to shutdown pubsub topic") + } + }(topic, ctx) + + subscription, err := pubsub.OpenSubscription(ctx, pubsubString) + if err != nil { + log.Err(err).Msg("failed to open pubsub topic") + return err + } + defer func(topic *pubsub.Subscription, ctx context.Context) { + err := topic.Shutdown(ctx) + if err != nil { + log.Err(err).Msg("fail to shutdown pubsub topic") + } + }(subscription, ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + msg, err := subscription.Receive(ctx) + log.Debug().Msg("received thumbnail generation request from pubsub topic") + if err != nil { + log.Err(err).Msg("failed to receive message from pubsub topic") + msg.Ack() + continue + } + if msg == nil { + log.Warn().Msg("received nil message from pubsub topic") + msg.Ack() + continue + } + groupId, err := uuid.Parse(msg.Metadata["group_id"]) + if err != nil { + log.Error().Err(err).Str("group_id", msg.Metadata["group_id"]).Msg("failed to parse group ID from message metadata") + } + attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"]) + if err != nil { + log.Error().Err(err).Str("attachment_id", msg.Metadata["attachment_id"]).Msg("failed to parse attachment ID from message metadata") + } + err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"]) + if err != nil { + log.Err(err).Msg("failed to create thumbnail") + } + msg.Ack() + } + } + }) + + if cfg.Options.GithubReleaseCheck { + runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) { + log.Debug().Msg("running get latest github release") + err := app.services.BackgroundService.GetLatestGithubRelease(context.Background()) + if err != nil { + log.Error().Err(err).Msg("failed to get latest github release") + } + })) + } + + if cfg.Debug.Enabled { + runner.AddFunc("debug", func(ctx context.Context) error { + debugserver := http.Server{ + Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port), + Handler: app.debugRouter(), + ReadTimeout: cfg.Web.ReadTimeout, + WriteTimeout: cfg.Web.WriteTimeout, + IdleTimeout: cfg.Web.IdleTimeout, + } + + go func() { + <-ctx.Done() + _ = debugserver.Shutdown(context.Background()) + }() + + log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port) + return debugserver.ListenAndServe() + }) + // Print the configuration to the console + cfg.Print() + } +} diff --git a/backend/app/api/setup.go b/backend/app/api/setup.go new file mode 100644 index 00000000..08164c5d --- /dev/null +++ b/backend/app/api/setup.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/core/currencies" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" +) + +// setupStorageDir handles the creation and validation of the storage directory. +func setupStorageDir(cfg *config.Config) { + if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") { + raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./") + clean := filepath.Clean(raw) + absBase, err := filepath.Abs(clean) + if err != nil { + log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string") + } + storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath) + storageDir = strings.ReplaceAll(storageDir, "\\", "/") + if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase { + log.Fatal().Str("path", storageDir).Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path") + } + if err := os.MkdirAll(storageDir, 0o750); err != nil { + log.Fatal().Err(err).Msg("failed to create data directory") + } + } +} + +// setupDatabaseURL returns the database URL and ensures any required directories exist. +func setupDatabaseURL(cfg *config.Config) string { + databaseURL := "" + switch strings.ToLower(cfg.Database.Driver) { + case "sqlite3": + databaseURL = cfg.Database.SqlitePath + dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0] + dbDir := filepath.Dir(dbFilePath) + if err := os.MkdirAll(dbDir, 0o755); err != nil { + log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory") + } + case "postgres": + databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode) + if cfg.Database.Username != "" { + databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username) + } + if cfg.Database.Password != "" { + databaseURL += fmt.Sprintf(" password=%s", cfg.Database.Password) + } + if cfg.Database.SslRootCert != "" { + if _, err := os.Stat(cfg.Database.SslRootCert); err != nil || !os.IsNotExist(err) { + log.Fatal().Err(err).Str("path", cfg.Database.SslRootCert).Msg("SSL root certificate file does not accessible") + } + databaseURL += fmt.Sprintf(" sslrootcert=%s", cfg.Database.SslRootCert) + } + if cfg.Database.SslCert != "" { + if _, err := os.Stat(cfg.Database.SslCert); err != nil || !os.IsNotExist(err) { + log.Fatal().Err(err).Str("path", cfg.Database.SslCert).Msg("SSL certificate file does not accessible") + } + databaseURL += fmt.Sprintf(" sslcert=%s", cfg.Database.SslCert) + } + if cfg.Database.SslKey != "" { + if _, err := os.Stat(cfg.Database.SslKey); err != nil || !os.IsNotExist(err) { + log.Fatal().Err(err).Str("path", cfg.Database.SslKey).Msg("SSL key file does not accessible") + } + databaseURL += fmt.Sprintf(" sslkey=%s", cfg.Database.SslKey) + } + default: + log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver") + } + return databaseURL +} + +// loadCurrencies loads currency data from config if provided. +func loadCurrencies(cfg *config.Config) ([]currencies.CollectorFunc, error) { + collectFuncs := []currencies.CollectorFunc{ + currencies.CollectDefaults(), + } + if cfg.Options.CurrencyConfig != "" { + log.Info().Str("path", cfg.Options.CurrencyConfig).Msg("loading currency config file") + content, err := os.ReadFile(cfg.Options.CurrencyConfig) + if err != nil { + log.Error().Err(err).Str("path", cfg.Options.CurrencyConfig).Msg("failed to read currency config file") + return nil, err + } + collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content))) + } + return collectFuncs, nil +} diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 82c425c2..0963a160 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -160,7 +160,7 @@ func TestAttachmentRepo_UpdateNonPhotoDoesNotAffectPrimaryPhoto(t *testing.T) { // Create a photo attachment that will be primary photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, true) require.NoError(t, err) - + // Create a manual attachment (non-photo) manualAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Manual", Content: strings.NewReader("Manual content")}, attachment.TypeManual, false) require.NoError(t, err) @@ -202,7 +202,7 @@ func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) { // Step 1: Upload a photo first (this should become primary since it's the first photo) photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Item Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, false) require.NoError(t, err) - + // Cleanup t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID) @@ -213,10 +213,10 @@ func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) { require.NoError(t, err) assert.True(t, photoAttachment.Primary, "First photo should automatically become primary") - // Step 2: Add a PDF receipt (this should NOT affect the photo's primary status) + // Step 2: Add a PDF receipt (this should NOT affect the photo's primary status) pdfAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Receipt PDF", Content: strings.NewReader("PDF content")}, attachment.TypeReceipt, false) require.NoError(t, err) - + // Add to cleanup t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, pdfAttachment.ID) @@ -235,7 +235,7 @@ func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) { // Step 4: Test the actual item summary mapping (this is what determines the card display) updatedItem, err := tRepos.Items.GetOne(ctx, item.ID) require.NoError(t, err) - + // The item should have the photo's ID as the imageId assert.NotNil(t, updatedItem.ImageID, "Item should have an imageId") assert.Equal(t, photoAttachment.ID, *updatedItem.ImageID, "Item's imageId should match the photo attachment ID") @@ -248,7 +248,7 @@ func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) { // Create two photo attachments photo1, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 1", Content: strings.NewReader("Photo 1 content")}, attachment.TypePhoto, false) require.NoError(t, err) - + photo2, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 2", Content: strings.NewReader("Photo 2 content")}, attachment.TypePhoto, false) require.NoError(t, err) diff --git a/backend/internal/sys/config/conf_database.go b/backend/internal/sys/config/conf_database.go index 4e65d112..4cbbdaa9 100644 --- a/backend/internal/sys/config/conf_database.go +++ b/backend/internal/sys/config/conf_database.go @@ -18,6 +18,9 @@ type Database struct { Port string `yaml:"port"` Database string `yaml:"database"` SslMode string `yaml:"ssl_mode"` + SslRootCert string `yaml:"ssl_rootcert"` + SslCert string `yaml:"ssl_cert"` + SslKey string `yaml:"ssl_key"` SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"` PubSubConnString string `yaml:"pubsub_conn_string" conf:"default:mem://{{ .Topic }}"` } diff --git a/docs/en/configure/index.md b/docs/en/configure/index.md index d3aa5445..b3147d6b 100644 --- a/docs/en/configure/index.md +++ b/docs/en/configure/index.md @@ -35,10 +35,13 @@ aside: false | HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite | | HBOX_DATABASE_HOST | | sets the hostname for a postgres database | | HBOX_DATABASE_PORT | | sets the port for a postgres database | -| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection | -| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection | +| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection (optional if using cert auth) | +| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection (optional if using cert auth) | | HBOX_DATABASE_DATABASE | | sets the database for a postgres connection | | HBOX_DATABASE_SSL_MODE | | sets the sslmode for a postgres connection | +| HBOX_DATABASE_SSL_CERT | | sets the sslcert for a postgres connection (should be a path) | +| HBOX_DATABASE_SSL_KEY | | sets the sslkey for a postgres connection (should be a path) | +| HBOX_DATABASE_SSL_ROOTCERT | | sets the sslrootcert for a postgres connection (should be a path) | | HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases | | HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels | | HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |