dealspace/cmd/server/main.go

178 lines
3.5 KiB
Go

package main
import (
"embed"
"encoding/hex"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"github.com/mish/dealspace/api"
"github.com/mish/dealspace/lib"
)
//go:embed website
var websiteEmbed embed.FS
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("config: %v", err)
}
// Resolve migration path relative to binary or working directory
migrationPath := findMigration()
db, err := lib.OpenDB(cfg.DBPath, migrationPath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer db.Close()
store, err := lib.NewLocalStore(cfg.StorePath)
if err != nil {
log.Fatalf("object store: %v", err)
}
// Always seed super admin accounts on startup
seedSuperAdmins(db)
// Seed demo data if SEED_DEMO=true
if os.Getenv("SEED_DEMO") == "true" {
seedDemoData(db, cfg)
}
// Website FS: embedded files under website/
websiteFS, err := fs.Sub(websiteEmbed, "website")
if err != nil {
log.Fatalf("embed website: %v", err)
}
router := api.NewRouter(db, cfg, store, websiteFS, nil)
addr := ":" + cfg.Port
log.Printf("dealspace starting on %s (env=%s)", addr, cfg.Env)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("server: %v", err)
}
}
func loadConfig() (*lib.Config, error) {
// Load .env if it exists
loadDotEnv()
masterKeyHex := os.Getenv("MASTER_KEY")
if masterKeyHex == "" {
return nil, fmt.Errorf("MASTER_KEY environment variable required")
}
masterKey, err := hex.DecodeString(masterKeyHex)
if err != nil {
return nil, fmt.Errorf("MASTER_KEY must be hex: %w", err)
}
if len(masterKey) != 32 {
return nil, fmt.Errorf("MASTER_KEY must be 32 bytes (64 hex chars)")
}
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "dealspace.db"
}
storePath := os.Getenv("STORE_PATH")
if storePath == "" {
storePath = "store"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
env := os.Getenv("ENV")
if env == "" {
env = "development"
}
// JWT secret derived from master key
jwtSecret, err := lib.DeriveProjectKey(masterKey, "jwt-signing")
if err != nil {
return nil, fmt.Errorf("derive jwt secret: %w", err)
}
// Backdoor OTP code for dev/testing
backdoorCode := os.Getenv("BACKDOOR_CODE")
cfg := &lib.Config{
MasterKey: masterKey,
DBPath: dbPath,
StorePath: storePath,
Port: port,
Env: env,
JWTSecret: jwtSecret,
BackdoorCode: backdoorCode,
}
// Initialize mailer
cfg.Mailer = lib.NewMailer(cfg)
return cfg, nil
}
func loadDotEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range splitLines(string(data)) {
if line == "" || line[0] == '#' {
continue
}
for i := 0; i < len(line); i++ {
if line[i] == '=' {
key := line[:i]
val := line[i+1:]
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
break
}
}
}
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
line := s[start:i]
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
lines = append(lines, line)
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func findMigration() string {
candidates := []string{
"migrations/001_initial.sql",
filepath.Join(filepath.Dir(os.Args[0]), "migrations/001_initial.sql"),
"/opt/dealspace/migrations/001_initial.sql",
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "" // no migration found, skip
}