254 lines
6.5 KiB
Go
254 lines
6.5 KiB
Go
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)
|
|||
|
|
}
|