Initial commit
This commit is contained in:
commit
c578b91c2d
46 files changed
+3241
No files matched your search
@@ -0,0 +1,254 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user