// Command school is the Compass / NHS service (school.lab.audasmedia.com.au). // // Phase 1 skeleton: Go service, SQLite schema (AGENTS.md ยง7), the service's own // session login, HTMX server-rendered shell, and GET /health. It does not scan // the school; the Compass login and fetch are Phase 2. package main import ( "context" "crypto/rand" "errors" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "golang.org/x/crypto/bcrypt" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/web" ) // version is injected with -ldflags at build time. var version = "dev" // seedChildren is the fixed set from AGENTS.md: Harry and Finn. var seedChildren = []string{"Harry", "Finn"} func main() { if len(os.Args) > 1 { switch os.Args[1] { case "hash-password": hashPassword(os.Args[2:]) return case "gen-secret": genSecret() return case "parse": parseCommand(os.Args[2:]) return case "session": sessionCommand(os.Args[2:]) return case "scan": scanCommand(os.Args[2:]) return case "items": itemsCommand(os.Args[2:]) return case "brief": briefCommand(os.Args[2:]) return case "calendar": calendarCommand(os.Args[2:]) return case "version": fmt.Println(version) return } } if err := run(); err != nil { fmt.Fprintln(os.Stderr, "school:", err) os.Exit(1) } } func run() error { started := time.Now() cfg, err := config.Load() if err != nil { return err } cfg.Version = version log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) slog.SetDefault(log) st, err := store.Open(cfg.DBPath) if err != nil { return err } defer st.Close() if err := st.Migrate(context.Background()); err != nil { return err } if err := st.SeedChildren(context.Background(), seedChildren...); err != nil { return err } am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure, cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) if err != nil { return err } if !am.Hashed() { log.Warn("admin password is plaintext; set SCHOOL_ADMIN_PASSWORD_HASH to use bcrypt") } srv, err := web.New(cfg, st, am, log) if err != nil { return err } httpServer := &http.Server{ Addr: cfg.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) go func() { log.Info("school service listening", "addr", cfg.Addr, "db", cfg.DBPath, "env", cfg.AppEnv, "version", version) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) select { case err := <-errCh: return err case sig := <-stop: log.Info("shutting down", "signal", sig.String()) } ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second) defer cancelShutdown() if err := httpServer.Shutdown(ctxShutdown); err != nil { return fmt.Errorf("shutdown: %w", err) } log.Info("stopped", "uptime", time.Since(started).Round(time.Second).String()) return nil } // hashPassword prints a bcrypt hash for the password given as an argument or // read from stdin. It never prints the password itself. func hashPassword(args []string) { var password string if len(args) > 0 { password = args[0] } else { b, err := io.ReadAll(os.Stdin) if err != nil { fmt.Fprintln(os.Stderr, "school:", err) os.Exit(1) } password = string(b) } if password == "" { fmt.Fprintln(os.Stderr, "school: empty password") os.Exit(2) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { fmt.Fprintln(os.Stderr, "school:", err) os.Exit(1) } fmt.Println(string(hash)) } // genSecret prints a fresh 32-byte hex secret. Use it to set // SCHOOL_SESSION_SECRET without pasting a secret into a shell history file by // hand. It never reads or stores anything. func genSecret() { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { fmt.Fprintln(os.Stderr, "school:", err) os.Exit(1) } fmt.Printf("%x\n", b) }