Files
uPortal/cmd/server/main.go
T
2026-10-15 23:25:46 +03:00

255 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}