Skip to content
Merged
4 changes: 2 additions & 2 deletions cmd/webhix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"os/signal"
"syscall"

"github.com/GaIsBAX/Webhix/internal/cli"
"github.com/GaIsBAX/Webhix/internal/app"
"github.com/GaIsBAX/Webhix/internal/config"
_ "github.com/GaIsBAX/Webhix/pkg"
)
Expand All @@ -26,7 +26,7 @@ func main() {
os.Exit(1)
}

if err := cli.Run(ctx, cfg, os.Args[1:]); err != nil {
if err := app.Start(ctx, cfg, os.Args[1:]); err != nil {
slog.Error("app run", "err", err)
os.Exit(1)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand All @@ -23,6 +24,5 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)
98 changes: 32 additions & 66 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,121 +2,87 @@ package app

import (
"context"
"fmt"
"io/fs"
"log/slog"
"net/http"
"time"

"github.com/GaIsBAX/Webhix/internal/config"
"github.com/GaIsBAX/Webhix/internal/core"
"github.com/GaIsBAX/Webhix/internal/hub"
"github.com/GaIsBAX/Webhix/internal/server"
"github.com/GaIsBAX/Webhix/internal/server/middleware"
"github.com/GaIsBAX/Webhix/internal/store"
"github.com/GaIsBAX/Webhix/internal/web"
)

const shutdownTimeout = 10 * time.Second

type App struct {
srv *http.Server
cfg *config.Config
deps *Deps
hub *hub.Hub
server *http.Server
config *config.Config
deps *dependencies
events *hub.Hub
services *services
}

func New(ctx context.Context, cfg *config.Config) (*App, error) {
mux := http.NewServeMux()

deps, err := NewDeps(ctx, cfg)
deps, err := newDependencies(ctx, cfg)
if err != nil {
return nil, err
}

eventHub := hub.New()

hookRepository := store.NewHookRepository(deps.DB.DB)
hookService := core.NewHookService(hookRepository)
hookHandler := server.NewHookHandler(mux, hookService, eventHub, server.HookHandlerOptions{
BaseURL: cfg.BaseURL,
MaxBodySize: cfg.MaxBodySize,
})

hookHandler.RegisterRoutes()
services := newServices(deps.repositories)
events := hub.New()

staticSub, err := fs.Sub(web.Static, "static")
mux, err := newMux(cfg, services, events)
if err != nil {
return nil, err
}
staticFS := http.FileServer(http.FS(staticSub))
mux.Handle("/ui/", http.StripPrefix("/ui/", staticFS))
mux.Handle("/", staticFS)

password, secretKey, err := resolveAuth(cfg)
handler, err := newHTTPHandler(cfg, mux)
if err != nil {
return nil, fmt.Errorf("auth setup: %w", err)
}

handler := http.Handler(mux)
handler = middleware.NewAuth(password, secretKey).Protect(handler)

if len(cfg.TrustedProxies) > 0 {
trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies)
if trustedProxies == nil {
return nil, fmt.Errorf("invalid trusted proxies")
}

handler = trustedProxies.BehindProxy(handler)
return nil, err
}

return &App{
srv: &http.Server{Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second},
cfg: cfg,
deps: deps,
hub: eventHub,
server: newHTTPServer(cfg, handler),
config: cfg,
deps: deps,
events: events,
services: services,
}, nil
}

func (a *App) Start(ctx context.Context) error {
errCh := make(chan error, 1)
serverErr := make(chan error, 1)
go func() {
slog.Info("webhix started", "addr", a.cfg.Addr, "base_url", a.cfg.BaseURL)
if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
slog.Info("webhix started", "addr", a.config.Addr, "base_url", a.config.BaseURL)
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()

select {
case err := <-errCh:
case err := <-serverErr:
return err
case <-ctx.Done():
return a.Shutdown()
}
}

func resolveAuth(cfg *config.Config) (password, secretKey string, err error) {
if cfg.Password == "" && cfg.SecretKey == "" {
return "", "", fmt.Errorf("auth is required: set WEBHIX_PASSWORD or WEBHIX_SECRET_KEY")
case <-ctx.Done():
return a.Shutdown(ctx)
}

return cfg.Password, cfg.SecretKey, nil
}

func (a *App) Shutdown() error {
func (a *App) Shutdown(ctx context.Context) error {
slog.Info("shutting down")
a.hub.Close()
a.events.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()

if err := a.srv.Shutdown(ctx); err != nil {
if err := a.server.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed, forcing close", "err", err)
if closeErr := a.srv.Close(); closeErr != nil {
if closeErr := a.server.Close(); closeErr != nil {
slog.Error("server close failed", "err", closeErr)
}
}

if err := a.deps.teardownInfrastructure(); err != nil {
if err := a.deps.close(); err != nil {
slog.Error("teardown error", "err", err)
return err
}

return nil
Expand Down
66 changes: 34 additions & 32 deletions internal/app/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,57 @@ package app
import (
"context"
"errors"
"fmt"

"github.com/GaIsBAX/Webhix/internal/config"
"github.com/GaIsBAX/Webhix/internal/repos"
"github.com/GaIsBAX/Webhix/internal/store"
)

type Deps struct {
DB *store.Database
cfg *config.Config
type dependencies struct {
db *store.Database
repositories *repositories
}

func NewDeps(ctx context.Context, cfg *config.Config) (*Deps, error) {
deps := &Deps{
cfg: cfg,
func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) {
db, err := store.New(ctx, cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}

if err := deps.setupInfrastructure(ctx); err != nil {
return nil, err
if err := db.Migrate(); err != nil {
if closeErr := db.Close(); closeErr != nil {
return nil, errors.Join(
fmt.Errorf("migrate database: %w", err),
fmt.Errorf("close database after migration failure: %w", closeErr),
)
}

return nil, fmt.Errorf("migrate database: %w", err)
}

return deps, nil
return &dependencies{
db: db,
repositories: newRepositories(db),
}, nil
}

func (d *Deps) setupInfrastructure(ctx context.Context) error {
var errs []error

database, err := store.New(ctx, d.cfg.DBPath)
if err != nil {
errs = append(errs, err)
}

d.DB = database
type repositories struct {
hook *repos.HookRepository
serve *repos.Serve
}

if d.DB != nil {
if err := d.DB.Migrate(); err != nil {
errs = append(errs, err)
}
func newRepositories(db *store.Database) *repositories {
return &repositories{
hook: repos.NewHookRepository(db.DB),
serve: repos.NewServe(db.DB),
}

return errors.Join(errs...)
}

func (d *Deps) teardownInfrastructure() error {
var errs []error

if d.DB != nil {
if err := d.DB.Close(); err != nil {
errs = append(errs, err)
}
func (d *dependencies) close() error {
if d.db != nil {
return d.db.Close()
}

return errors.Join(errs...)
return nil
}
109 changes: 109 additions & 0 deletions internal/app/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package app

import (
"fmt"
"io/fs"
"net/http"
"time"

"github.com/GaIsBAX/Webhix/internal/config"
"github.com/GaIsBAX/Webhix/internal/hub"
"github.com/GaIsBAX/Webhix/internal/server"
"github.com/GaIsBAX/Webhix/internal/server/middleware"
"github.com/GaIsBAX/Webhix/internal/web"
)

const readHeaderTimeout = 5 * time.Second

func newMux(cfg *config.Config, services *services, events *hub.Hub) (*http.ServeMux, error) {
mux := http.NewServeMux()

registerWebhookRoutes(mux, cfg, services.hook, events)

if err := registerStaticRoutes(mux); err != nil {
return nil, err
}

return mux, nil
}

func registerWebhookRoutes(
mux *http.ServeMux,
cfg *config.Config,
hookService server.HookService,
events *hub.Hub,
) {
handler := server.NewHookHandler(
mux,
hookService,
events,
server.HookHandlerOptions{
BaseURL: cfg.BaseURL,
MaxBodySize: cfg.MaxBodySize,
ReadOnly: cfg.ReadOnly,
},
)

handler.RegisterRoutes()
}

func registerStaticRoutes(mux *http.ServeMux) error {
staticSub, err := fs.Sub(web.Static, "static")
if err != nil {
return err
}

staticHandler := http.FileServer(http.FS(staticSub))

mux.Handle("/ui/", http.StripPrefix("/ui/", staticHandler))
mux.Handle("/", staticHandler)

return nil
}

func newHTTPHandler(cfg *config.Config, mux *http.ServeMux) (http.Handler, error) {
handler := http.Handler(mux)

auth, err := newAuthMiddleware(cfg)
if err != nil {
return nil, err
}

handler = auth.Protect(handler)

if len(cfg.TrustedProxies) > 0 {
trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies)
if trustedProxies == nil {
return nil, fmt.Errorf("invalid trusted proxies")
}

handler = trustedProxies.BehindProxy(handler)
}

return handler, nil
}

func newAuthMiddleware(cfg *config.Config) (*middleware.Auth, error) {
password, secretKey, err := authCredentials(cfg)
if err != nil {
return nil, fmt.Errorf("auth setup: %w", err)
}

return middleware.NewAuth(password, secretKey), nil
}

func newHTTPServer(cfg *config.Config, handler http.Handler) *http.Server {
return &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
}
}

func authCredentials(cfg *config.Config) (password, secretKey string, err error) {
if cfg.Password == "" && cfg.SecretKey == "" {
return "", "", fmt.Errorf("auth is required: set WEBHIX_PASSWORD or WEBHIX_SECRET_KEY")
}

return cfg.Password, cfg.SecretKey, nil
}
Loading