551 lines
13 KiB
Go
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
|
|
}
|