// backend\setup\main.go package main import ( "bufio" "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(), 60*time.Second) defer cancel() if *reset { if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil { if errors.Is(err, errResetAborted) { fmt.Println("Reset abgebrochen. Es wurden keine Daten gelöscht.") return } 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) waitForExit() } var errResetAborted = errors.New("reset abgebrochen") func confirmDatabaseReset(databaseName string) (bool, error) { reader := bufio.NewReader(os.Stdin) fmt.Println("") fmt.Println("WARNUNG: Der Datenbank-Reset löscht alle Daten unwiderruflich.") fmt.Printf("Datenbank: %s\n", databaseName) fmt.Println("") fmt.Printf("Bitte gib den Datenbanknamen %q ein, um fortzufahren: ", databaseName) input, err := reader.ReadString('\n') if err != nil { return false, err } input = strings.TrimSpace(input) return input == databaseName, nil } 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 } confirmed, err := confirmDatabaseReset(databaseName) if err != nil { return err } if !confirmed { return errResetAborted } 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[], token_version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS user_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, browser TEXT NOT NULL DEFAULT '', operating_system TEXT NOT NULL DEFAULT '', device_type TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', ip_address TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ ) `, ` 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_units ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, 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 dashboard_widgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', x INTEGER NOT NULL DEFAULT 0, y INTEGER NOT NULL DEFAULT 0, w INTEGER NOT NULL DEFAULT 4, h INTEGER NOT NULL DEFAULT 2, config JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS dashboard_settings ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, settings JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` 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 '', ip_address TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '', loan_status TEXT NOT NULL DEFAULT 'Verfügbar', is_bao_device BOOLEAN NOT NULL DEFAULT false, comment TEXT NOT NULL DEFAULT '', milestone_recording_server_id TEXT NOT NULL DEFAULT '', milestone_hardware_id TEXT NOT NULL DEFAULT '', milestone_display_name TEXT NOT NULL DEFAULT '', firmware_version TEXT NOT NULL DEFAULT '', milestone_port TEXT NOT NULL DEFAULT '', milestone_enabled BOOLEAN NOT NULL DEFAULT true, milestone_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), milestone_hardware_id TEXT NOT NULL, milestone_device_id TEXT NOT NULL, device_type TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '', short_name TEXT NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT true, recording_enabled BOOLEAN NOT NULL DEFAULT false, recording_storage_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (milestone_hardware_id, milestone_device_id, device_type) ) `, ` CREATE TABLE IF NOT EXISTS device_manufacturers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS device_locations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, 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(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS operations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), operation_number TEXT NOT NULL, operation_name TEXT NOT NULL DEFAULT '', offense TEXT NOT NULL DEFAULT '', case_worker TEXT NOT NULL DEFAULT '', target_object TEXT NOT NULL DEFAULT '', kw_address TEXT NOT NULL DEFAULT '', operation_leader TEXT NOT NULL DEFAULT '', operation_team TEXT NOT NULL DEFAULT '', legend TEXT NOT NULL DEFAULT '', camera TEXT NOT NULL DEFAULT '', router TEXT NOT NULL DEFAULT '', technology TEXT NOT NULL DEFAULT '', switchbox TEXT NOT NULL DEFAULT '', hard_drive TEXT NOT NULL DEFAULT '', laptop TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', milestone_storage TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`, ` CREATE TABLE IF NOT EXISTS operation_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), operation_id UUID NOT NULL REFERENCES operations(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(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL DEFAULT 'info', title TEXT NOT NULL, message TEXT NOT NULL DEFAULT '', silent BOOLEAN NOT NULL DEFAULT false, entity_type TEXT NOT NULL DEFAULT '', entity_id UUID, data JSONB NOT NULL DEFAULT '{}'::JSONB, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS milestone_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), host TEXT NOT NULL DEFAULT '', username TEXT NOT NULL DEFAULT '', password_encrypted BYTEA, skip_tls_verify BOOLEAN NOT NULL DEFAULT false, access_token_encrypted BYTEA, token_type TEXT NOT NULL DEFAULT '', token_expires_at TIMESTAMPTZ, token_updated_at TIMESTAMPTZ, server_id TEXT NOT NULL DEFAULT '', server_name TEXT NOT NULL DEFAULT '', server_version TEXT NOT NULL DEFAULT '', server_updated_at TIMESTAMPTZ, server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http', server_folders_agent_port TEXT NOT NULL DEFAULT '8099', server_folders_agent_token_encrypted BYTEA, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` ALTER TABLE milestone_settings ADD COLUMN IF NOT EXISTS server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http' `, ` ALTER TABLE milestone_settings ADD COLUMN IF NOT EXISTS server_folders_agent_port TEXT NOT NULL DEFAULT '8099' `, ` ALTER TABLE milestone_settings ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA `, ` CREATE TABLE IF NOT EXISTS notification_preferences ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, in_app_enabled BOOLEAN NOT NULL DEFAULT true, sound_enabled BOOLEAN NOT NULL DEFAULT false, sound_name TEXT NOT NULL DEFAULT 'default', email_enabled BOOLEAN NOT NULL DEFAULT false, operation_updates BOOLEAN NOT NULL DEFAULT true, operation_journals BOOLEAN NOT NULL DEFAULT true, device_updates BOOLEAN NOT NULL DEFAULT true, device_journals BOOLEAN NOT NULL DEFAULT true, system_messages BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS feedback ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, message TEXT NOT NULL, log JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS user_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE SET NULL, level TEXT NOT NULL DEFAULT 'info', category TEXT NOT NULL DEFAULT '', action TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', user_snapshot JSONB NOT NULL DEFAULT '{}'::JSONB, request JSONB NOT NULL DEFAULT '{}'::JSONB, details JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, ` CREATE TABLE IF NOT EXISTS camera_firmware_cache ( manufacturer TEXT NOT NULL, normalized_model TEXT NOT NULL, latest_version TEXT NOT NULL DEFAULT '', support_url TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '', check_error TEXT NOT NULL DEFAULT '', support_badges JSONB NOT NULL DEFAULT '[]'::JSONB, checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (manufacturer, normalized_model) ) `, ` CREATE TABLE IF NOT EXISTS camera_firmware_refresh_state ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), last_checked_at TIMESTAMPTZ, last_checked_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_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_user_sessions_user_id ON user_sessions(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at)`, `CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen_at ON user_sessions(last_seen_at)`, `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 INDEX IF NOT EXISTS idx_user_units_name ON user_units(name)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`, `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`, `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`, `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_recording_storage_id ON milestone_hardware_child_devices(recording_storage_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_ip_address ON devices(ip_address)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_milestone_hardware_id_unique ON devices(milestone_hardware_id) WHERE milestone_hardware_id <> ''`, `CREATE INDEX IF NOT EXISTS idx_devices_milestone_recording_server_id ON devices(milestone_recording_server_id)`, `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_manufacturers_name ON device_manufacturers(name)`, `CREATE INDEX IF NOT EXISTS idx_device_locations_name ON device_locations(name)`, `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)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_operations_operation_number_unique ON operations(lower(operation_number))`, `CREATE INDEX IF NOT EXISTS idx_operations_operation_name ON operations(operation_name)`, `CREATE INDEX IF NOT EXISTS idx_operations_offense ON operations(offense)`, `CREATE INDEX IF NOT EXISTS idx_operations_kw_address ON operations(kw_address)`, `CREATE INDEX IF NOT EXISTS idx_operations_operation_leader ON operations(operation_leader)`, `CREATE INDEX IF NOT EXISTS idx_operations_operation_team ON operations(operation_team)`, `CREATE INDEX IF NOT EXISTS idx_operation_history_operation_id ON operation_history(operation_id)`, `CREATE INDEX IF NOT EXISTS idx_operation_history_user_id ON operation_history(user_id)`, `CREATE INDEX IF NOT EXISTS idx_operation_history_created_at ON operation_history(created_at)`, `CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)`, `CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`, `CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`, `CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`, `CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`, `CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`, `CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`, `CREATE INDEX IF NOT EXISTS idx_user_logs_user_id ON user_logs(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_logs_level ON user_logs(level)`, `CREATE INDEX IF NOT EXISTS idx_user_logs_category ON user_logs(category)`, `CREATE INDEX IF NOT EXISTS idx_user_logs_created_at ON user_logs(created_at)`, `CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`, `CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_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,dashboard:read,settings:read,administration:read,administration:write,users:read,users:write,teams:read,teams:write,devices:read,devices:write,operations:read,operations:write,calendar:read,documents:read,reports:read", )) 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 } func waitForExit() { fmt.Println("") fmt.Print("Drücke Enter, um das Setup zu beenden...") reader := bufio.NewReader(os.Stdin) _, _ = reader.ReadString('\n') }