package main import ( "context" "database/sql" "flag" "log" "net/http" "os" "strings" "time" _ "github.com/mattn/go-sqlite3" "uPortal/internal/auth" "uPortal/internal/env" "uPortal/internal/jitsi" applog "uPortal/internal/log" "uPortal/internal/template" "uPortal/internal/user" "uPortal/internal/web" ) var ( DBPath = "uportal.db" Addr = "127.0.0.1:8080" ) func main() { // Load .env first (lowest priority; system env wins). if err := env.LoadEnvDefault(); err != nil { log.Printf("Warning: no .env file found: %v", err) } flag.StringVar(&DBPath, "db", DBPath, "path to SQLite database file") flag.StringVar(&Addr, "addr", Addr, "listen address") flag.Parse() logLevelStr := os.Getenv("LOG_LEVEL") if logLevelStr == "" { logLevelStr = "info" } logLevel, err := applog.ParseLevel(logLevelStr) if err != nil { log.Fatalf("Invalid LOG_LEVEL %q: %v", logLevelStr, err) } var appLog = applog.New(logLevel, os.Stdout) db, err := sql.Open("sqlite3", DBPath) if err != nil { appLog.Error("Failed to open database: %v", err) os.Exit(1) } defer db.Close() db.SetMaxOpenConns(1) // SQLite does not support concurrent writes if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { appLog.Error("Failed to set PRAGMA foreign_keys: %v", err) os.Exit(1) } mustExec(db, ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT UNIQUE NOT NULL, password TEXT NOT NULL, displayname TEXT, superuser INTEGER NOT NULL DEFAULT 0 ) `) mustExec(db, ` CREATE TABLE IF NOT EXISTS metadatas ( id INTEGER PRIMARY KEY, data TEXT NOT NULL ) `) mustExec(db, ` CREATE TABLE IF NOT EXISTS avatars ( id INTEGER PRIMARY KEY, data BLOB ) `) mustExec(db, ` CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, userid INTEGER NOT NULL, device TEXT, address TEXT, expiresat DATETIME NOT NULL, createdat DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (userid) REFERENCES users(id) ON DELETE CASCADE ) `) mustExec(db, ` CREATE TABLE IF NOT EXISTS invites ( token TEXT PRIMARY KEY, uses INTEGER NOT NULL DEFAULT 1, expiresat DATETIME NOT NULL, createdat DATETIME DEFAULT CURRENT_TIMESTAMP ) `) ctx := context.Background() userRepo, err := user.NewRepository(db) if err != nil { appLog.Error("Failed to create user repository: %v", err) os.Exit(1) } authRepo, err := auth.NewRepository(db) if err != nil { appLog.Error("Failed to create auth repository: %v", err) os.Exit(1) } seed(ctx, userRepo, authRepo, db, appLog) views, err := template.NewService("templates") if err != nil { appLog.Error("Failed to initialize template engine: %v", err) os.Exit(1) } jitsiSecret := os.Getenv("JITSI_SECRET") jitsiAppID := os.Getenv("JITSI_APP_ID") jitsiDomain := os.Getenv("JITSI_DOMAIN") siteDomain := os.Getenv("SITE_DOMAIN") var jitsiCfg *jitsi.Config if jitsiSecret != "" && jitsiAppID != "" { if jitsiDomain == "" { jitsiDomain = "meet.jit.si" } jitsiCfg = &jitsi.Config{ Secret: jitsiSecret, AppID: jitsiAppID, AppName: "uPortal", Domain: jitsiDomain, SiteDomain: siteDomain, } } // Trusted domains bypass the external-redirect warning. The Jitsi domain // is always trusted, even if not listed in the env var. trustedDomains := []string{} for _, d := range strings.Split(os.Getenv("TRUSTED_DOMAIINS"), ",") { if d = strings.TrimSpace(d); d != "" { trustedDomains = append(trustedDomains, d) } } if jitsiDomain != "" { trustedDomains = append(trustedDomains, jitsiDomain) } webSvc := web.New(userRepo, authRepo, views, db, jitsiCfg, trustedDomains, appLog) // ── Start background session cleanup ── const defaultCleanupInterval = 5 * time.Minute go func() { cleanupInterval := defaultCleanupInterval if v := os.Getenv("SESSION_CLEANUP_INTERVAL"); v != "" { if d, err := time.ParseDuration(v); err == nil && d > 0 { cleanupInterval = d } } // Run once on startup deleted, err := authRepo.DeleteExpiredSessions(ctx) if err != nil { appLog.Warn("session cleanup on startup failed: %v", err) } else if deleted > 0 { appLog.Info("cleaned up %d expired sessions on startup", deleted) } // Then periodically ticker := time.NewTicker(cleanupInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: deleted, err := authRepo.DeleteExpiredSessions(ctx) if err != nil { appLog.Warn("session cleanup failed: %v", err) } else if deleted > 0 { appLog.Info("cleaned up %d expired sessions", deleted) } } } }() mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) webSvc.Register(mux) appLog.Info("uPortal listening on %s", Addr) appLog.Error("server stopped: %v", http.ListenAndServe(Addr, mux)) os.Exit(1) } // mustExec runs a DDL/DML statement and fatally logs on error. func mustExec(db *sql.DB, ddl string) { if _, err := db.Exec(ddl); err != nil { log.Fatalf("Migration failed: %v\nSQL: %s", err, ddl) } } // seed creates the initial admin user and an invite code when the users // table is empty (first run). func seed(ctx context.Context, userRepo *user.Repository, authRepo *auth.Repository, db *sql.DB, appLog *applog.Logger) { count, err := userRepo.GetUserCount(ctx) if err != nil { appLog.Error("Failed to check user count: %v", err) os.Exit(1) } if count > 0 { return } appLog.Info("Seeding: no users found, creating admin account and first invite") hashed, err := auth.HashPassword("admin") if err != nil { appLog.Error("Failed to hash admin password: %v", err) os.Exit(1) } user, err := userRepo.CreateUser(ctx, "admin", hashed) if err != nil { appLog.Error("Failed to create admin user: %v", err) os.Exit(1) } appLog.Info("Seeded admin user — id=%d login=admin password=admin", user.Id) // userRepo.СreateUser does not set superuser, so update it directly. if _, err := db.Exec("UPDATE users SET superuser = 1 WHERE id = ?", user.Id); err != nil { appLog.Error("Failed to set admin superuser: %v", err) os.Exit(1) } appLog.Info("Admin user promoted to superuser") token, err := auth.GenerateToken(32) if err != nil { appLog.Error("Failed to generate invite token: %v", err) os.Exit(1) } if _, err := authRepo.CreateInvite(ctx, token, 1, time.Now().Add(24*time.Hour)); err != nil { appLog.Error("Failed to create seed invite: %v", err) os.Exit(1) } appLog.Info("Created seed invite — token=%s (expires in 24h)", token) }