teg/backend/setup/main.go
2026-05-12 15:29:56 +02:00

551 lines
13 KiB
Go

// 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
}