// backend\setup\main.go package main import ( "context" "crypto/rand" "encoding/base64" "errors" "flag" "fmt" "log" "net/url" "os" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/joho/godotenv" "golang.org/x/crypto/bcrypt" ) func main() { reset := flag.Bool("reset", false, "Datenbank komplett löschen und neu anlegen") flag.Parse() if err := godotenv.Load(".env", "backend/.env"); err != nil { log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks") } databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable") parsedURL, err := url.Parse(databaseURL) if err != nil { log.Fatal(err) } databaseName := strings.TrimPrefix(parsedURL.Path, "/") if databaseName == "" { log.Fatal("DATABASE_URL enthält keinen Datenbanknamen") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if *reset { if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil { log.Fatal(err) } } if err := createDatabaseIfNotExists(ctx, parsedURL, databaseName); err != nil { log.Fatal(err) } db, err := pgxpool.New(ctx, databaseURL) if err != nil { log.Fatal(err) } defer db.Close() if err := db.Ping(ctx); err != nil { log.Fatal(err) } if err := createSchema(ctx, db); err != nil { log.Fatal(err) } password, created, err := createAdminIfNotExists(ctx, db) if err != nil { log.Fatal(err) } printAdminResult(password, created) } func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error { if databaseName == "postgres" || databaseName == "template0" || databaseName == "template1" { return fmt.Errorf("datenbank %q darf nicht per -reset gelöscht werden", databaseName) } maintenanceURL := *parsedURL maintenanceURL.Path = "/postgres" db, err := pgxpool.New(ctx, maintenanceURL.String()) if err != nil { return err } defer db.Close() var exists bool err = db.QueryRow( ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, databaseName, ).Scan(&exists) if err != nil { return err } if !exists { fmt.Printf("Datenbank %q existiert nicht, Reset übersprungen\n", databaseName) return nil } fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName) _, err = db.Exec( ctx, ` SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid() `, databaseName, ) if err != nil { return err } _, err = db.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdentifier(databaseName)) if err != nil { return err } fmt.Printf("Datenbank %q wurde gelöscht\n", databaseName) return nil } func createDatabaseIfNotExists(ctx context.Context, parsedURL *url.URL, databaseName string) error { maintenanceURL := *parsedURL maintenanceURL.Path = "/postgres" db, err := pgxpool.New(ctx, maintenanceURL.String()) if err != nil { return err } defer db.Close() var exists bool err = db.QueryRow( ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, databaseName, ).Scan(&exists) if err != nil { return err } if exists { fmt.Printf("Datenbank %q existiert bereits\n", databaseName) return nil } _, err = db.Exec(ctx, `CREATE DATABASE `+quoteIdentifier(databaseName)) if err != nil { return err } fmt.Printf("Datenbank %q wurde erstellt\n", databaseName) return nil } func createSchema(ctx context.Context, db *pgxpool.Pool) error { queries := []string{ `CREATE EXTENSION IF NOT EXISTS pgcrypto`, ` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT UNIQUE, display_name TEXT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, avatar TEXT, unit TEXT, user_group TEXT, rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[], created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS teams ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL UNIQUE, initial TEXT NOT NULL, href TEXT NOT NULL DEFAULT '#', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS user_teams ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, team_id) ) `, ` CREATE TABLE IF NOT EXISTS devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), inventory_number TEXT NOT NULL, manufacturer TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', serial_number TEXT NOT NULL DEFAULT '', mac_address TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '', loan_status TEXT NOT NULL DEFAULT 'Verfügbar', comment TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS device_relations ( device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (device_id, related_device_id), CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id) ) `, ` CREATE TABLE IF NOT EXISTS device_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, changes JSONB NOT NULL DEFAULT '[]'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`, `CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, `CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`, `CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`, `CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`, `CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`, `CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`, `CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`, `CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`, } for _, query := range queries { if _, err := db.Exec(ctx, query); err != nil { return err } } fmt.Println("Tabellen und Indizes wurden erstellt oder existieren bereits") return nil } func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool, error) { adminUsername := env("ADMIN_USERNAME", "admin") adminDisplayName := env("ADMIN_DISPLAY_NAME", "Administrator") adminEmail := env("ADMIN_EMAIL", "admin@example.com") adminAvatar := env("ADMIN_AVATAR", "") adminUnit := env("ADMIN_UNIT", "Administration") adminGroup := env("ADMIN_GROUP", "admin") adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write")) adminTeamName := env("ADMIN_TEAM_NAME", "Administration") adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A") adminTeamHref := env("ADMIN_TEAM_HREF", "#") teamID, err := ensureTeam(ctx, db, adminTeamName, adminTeamInitial, adminTeamHref) if err != nil { return "", false, err } var existingUserID string err = db.QueryRow( ctx, ` SELECT id FROM users WHERE username = $1 OR email = $2 LIMIT 1 `, adminUsername, adminEmail, ).Scan(&existingUserID) if err == nil { if err := assignUserToTeam(ctx, db, existingUserID, teamID); err != nil { return "", false, err } return "", false, nil } if !errors.Is(err, pgx.ErrNoRows) { return "", false, err } password, err := generatePassword(24) if err != nil { return "", false, err } passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", false, err } var userID string err = db.QueryRow( ctx, ` INSERT INTO users ( username, display_name, email, password_hash, avatar, unit, user_group, rights ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::TEXT[]) RETURNING id `, adminUsername, adminDisplayName, adminEmail, string(passwordHash), adminAvatar, adminUnit, adminGroup, toPostgresTextArray(adminRights), ).Scan(&userID) if err != nil { return "", false, err } if err := assignUserToTeam(ctx, db, userID, teamID); err != nil { return "", false, err } return password, true, nil } func ensureTeam(ctx context.Context, db *pgxpool.Pool, name string, initial string, href string) (string, error) { name = strings.TrimSpace(name) initial = strings.TrimSpace(initial) href = strings.TrimSpace(href) if name == "" { name = "Administration" } if initial == "" { initial = strings.ToUpper(string([]rune(name)[0])) } if href == "" { href = "#" } var teamID string err := db.QueryRow( ctx, ` INSERT INTO teams (name, initial, href) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET initial = EXCLUDED.initial, href = EXCLUDED.href, updated_at = now() RETURNING id `, name, initial, href, ).Scan(&teamID) return teamID, err } func assignUserToTeam(ctx context.Context, db *pgxpool.Pool, userID string, teamID string) error { _, err := db.Exec( ctx, ` INSERT INTO user_teams (user_id, team_id) VALUES ($1, $2) ON CONFLICT (user_id, team_id) DO NOTHING `, userID, teamID, ) return err } func generatePassword(length int) (string, error) { randomBytes := make([]byte, length) if _, err := rand.Read(randomBytes); err != nil { return "", err } password := base64.RawURLEncoding.EncodeToString(randomBytes) if len(password) > length { password = password[:length] } return password, nil } func printAdminResult(password string, created bool) { adminUsername := env("ADMIN_USERNAME", "admin") adminEmail := env("ADMIN_EMAIL", "admin@example.com") adminTeamName := env("ADMIN_TEAM_NAME", "Administration") fmt.Println("") if created { fmt.Println("=================================================") fmt.Println("Admin-User wurde erstellt") fmt.Println("=================================================") fmt.Printf("Benutzername: %s\n", adminUsername) fmt.Printf("E-Mail: %s\n", adminEmail) fmt.Printf("Team: %s\n", adminTeamName) fmt.Printf("Passwort: %s\n", password) fmt.Println("=================================================") fmt.Println("Bitte Passwort sicher speichern. Es wird nicht erneut angezeigt.") fmt.Println("") return } fmt.Println("=================================================") fmt.Println("Admin-User existiert bereits") fmt.Println("=================================================") fmt.Printf("Benutzername: %s\n", adminUsername) fmt.Printf("E-Mail: %s\n", adminEmail) fmt.Printf("Team: %s\n", adminTeamName) fmt.Println("Passwort: wird nicht angezeigt, da nur der Hash gespeichert wird") fmt.Println("=================================================") fmt.Println("") } func toPostgresTextArray(values []string) string { if len(values) == 0 { return "{}" } escaped := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, `"`, `\"`) escaped = append(escaped, `"`+value+`"`) } if len(escaped) == 0 { return "{}" } return "{" + strings.Join(escaped, ",") + "}" } func splitCSV(value string) []string { parts := strings.Split(value, ",") result := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { result = append(result, part) } } return result } func quoteIdentifier(value string) string { return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` } func env(key string, fallback string) string { value := os.Getenv(key) if value == "" { return fallback } return value }