diff options
Diffstat (limited to '')
55 files changed, 1600 insertions, 289 deletions
@@ -1,4 +1,4 @@ -tfstated +cover.out tfstate.db tfstate.db-shm tfstate.db-wal diff --git a/cmd/tfstated/main.go b/cmd/tfstated/main.go index e6c6272..e228d53 100644 --- a/cmd/tfstated/main.go +++ b/cmd/tfstated/main.go @@ -3,85 +3,50 @@ package main import ( "context" "fmt" - "io" - "log" "log/slog" - "net" - "net/http" "os" "os/signal" - "strconv" "sync" + "syscall" "time" + "git.adyxax.org/adyxax/tfstated/pkg/backend" "git.adyxax.org/adyxax/tfstated/pkg/database" - "git.adyxax.org/adyxax/tfstated/pkg/logger" + "git.adyxax.org/adyxax/tfstated/pkg/webui" ) -type Config struct { - Host string - Port string -} - func run( ctx context.Context, - config *Config, db *database.DB, - //args []string, getenv func(string) string, - //stdin io.Reader, - //stdout io.Writer, - stderr io.Writer, ) error { - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() - dataEncryptionKey := getenv("DATA_ENCRYPTION_KEY") - if dataEncryptionKey == "" { - return fmt.Errorf("the DATA_ENCRYPTION_KEY environment variable is not set") - } - if err := db.SetDataEncryptionKey(dataEncryptionKey); err != nil { - return err - } - versionsHistoryLimit := getenv("VERSIONS_HISTORY_LIMIT") - if versionsHistoryLimit != "" { - n, err := strconv.Atoi(versionsHistoryLimit) - if err != nil { - return fmt.Errorf("failed to parse the VERSIONS_HISTORY_LIMIT environment variable: %w", err) - } - db.SetVersionsHistoryLimit(n) - } - if err := db.InitAdminAccount(); err != nil { return err } - mux := http.NewServeMux() - addRoutes( - mux, - db, - ) + backend := backend.Run(ctx, db, getenv) + webui := webui.Run(ctx, db, getenv) - httpServer := &http.Server{ - Addr: net.JoinHostPort(config.Host, config.Port), - Handler: logger.Middleware(mux, false), - } + <-ctx.Done() + shutdownCtx := context.Background() + shutdownCtx, shutdownCancel := context.WithTimeout(shutdownCtx, 10*time.Second) + defer shutdownCancel() + + var wg sync.WaitGroup + wg.Add(2) go func() { - log.Printf("listening on %s\n", httpServer.Addr) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - fmt.Fprintf(stderr, "error listening and serving: %+v\n", err) + defer wg.Done() + if err := backend.Shutdown(shutdownCtx); err != nil { + slog.Error("error shutting down backend http server", "error", err) } }() - var wg sync.WaitGroup - wg.Add(1) go func() { defer wg.Done() - <-ctx.Done() - shutdownCtx := context.Background() - shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) - defer cancel() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - fmt.Fprintf(stderr, "error shutting down http server: %+v\n", err) + if err := webui.Shutdown(shutdownCtx); err != nil { + slog.Error("error shutting down webui http server", "error", err) } }() wg.Wait() @@ -93,7 +58,7 @@ func main() { ctx := context.Background() var opts *slog.HandlerOptions - if os.Getenv("TFSTATE_DEBUG") != "" { + if os.Getenv("TFSTATED_DEBUG") != "" { opts = &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, @@ -102,12 +67,11 @@ func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) slog.SetDefault(logger) - config := Config{ - Host: "0.0.0.0", - Port: "8080", - } - - db, err := database.NewDB(ctx, "./tfstate.db?_txlock=immediate") + db, err := database.NewDB( + ctx, + "./tfstate.db?_txlock=immediate", + os.Getenv, + ) if err != nil { fmt.Fprintf(os.Stderr, "database init error: %+v\n", err) os.Exit(1) @@ -116,13 +80,8 @@ func main() { if err := run( ctx, - &config, db, - //os.Args, os.Getenv, - //os.Stdin, - //os.Stdout, - os.Stderr, ); err != nil { fmt.Fprintf(os.Stderr, "%+v\n", err) os.Exit(1) diff --git a/cmd/tfstated/main_test.go b/cmd/tfstated/main_test.go index 82b7736..b28edbd 100644 --- a/cmd/tfstated/main_test.go +++ b/cmd/tfstated/main_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "sync" "testing" "time" @@ -14,31 +15,23 @@ import ( ) var baseURI = url.URL{ - Host: "127.0.0.1:8081", + Host: "127.0.0.1:8082", Path: "/", Scheme: "http", } var db *database.DB var adminPassword string +var adminPasswordMutex sync.Mutex func TestMain(m *testing.M) { - ctx := context.Background() - ctx, cancel := context.WithCancel(ctx) - config := Config{ - Host: "127.0.0.1", - Port: "8081", - } - _ = os.Remove("./test.db") - var err error - db, err = database.NewDB(ctx, "./test.db") - if err != nil { - fmt.Fprintf(os.Stderr, "%+v\n", err) - os.Exit(1) - } getenv := func(key string) string { switch key { case "DATA_ENCRYPTION_KEY": return "hP3ZSCnY3LMgfTQjwTaGrhKwdA0yXMXIfv67OJnntqM=" + case "TFSTATED_HOST": + return "127.0.0.1" + case "TFSTATED_PORT": + return "8082" case "VERSIONS_HISTORY_LIMIT": return "3" default: @@ -46,17 +39,26 @@ func TestMain(m *testing.M) { } } + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + _ = os.Remove("./test.db") + var err error + db, err = database.NewDB(ctx, "./test.db", getenv) + if err != nil { + fmt.Fprintf(os.Stderr, "%+v\n", err) + os.Exit(1) + } database.AdvertiseAdminPassword = func(password string) { + adminPasswordMutex.Lock() + defer adminPasswordMutex.Unlock() adminPassword = password } go run( ctx, - &config, db, getenv, - os.Stderr, ) - err = waitForReady(ctx, 5*time.Second, "http://127.0.0.1:8081/healthz") + err = waitForReady(ctx, 5*time.Second, "http://127.0.0.1:8082/healthz") if err != nil { fmt.Fprintf(os.Stderr, "%+v\n", err) os.Exit(1) @@ -84,6 +86,8 @@ func runHTTPRequest(method string, auth bool, uriRef *url.URL, body io.Reader, t return } if auth { + adminPasswordMutex.Lock() + defer adminPasswordMutex.Unlock() req.SetBasicAuth("admin", adminPassword) } resp, err := client.Do(req) @@ -119,14 +123,13 @@ func waitForReady( resp, err := client.Do(req) if err != nil { fmt.Printf("Error making request: %s\n", err.Error()) - continue - } - if resp.StatusCode == http.StatusOK { - fmt.Println("Endpoint is ready!") - resp.Body.Close() - return nil + } else { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + fmt.Println("Endpoint is ready!") + return nil + } } - resp.Body.Close() select { case <-ctx.Done(): @@ -1,10 +1,9 @@ module git.adyxax.org/adyxax/tfstated -go 1.23.3 - -require github.com/mattn/go-sqlite3 v1.14.24 +go 1.23.6 require ( - go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad // indirect - golang.org/x/crypto v0.29.0 // indirect + github.com/mattn/go-sqlite3 v1.14.24 + go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad + golang.org/x/crypto v0.33.0 ) @@ -2,5 +2,5 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad h1:QYbHaaFqx6hMor1L6iMSmyhMFvXQXhKaNk9nefug07M= go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= diff --git a/cmd/tfstated/delete.go b/pkg/backend/delete.go index d594073..61007c4 100644 --- a/cmd/tfstated/delete.go +++ b/pkg/backend/delete.go @@ -1,4 +1,4 @@ -package main +package backend import ( "fmt" diff --git a/cmd/tfstated/get.go b/pkg/backend/get.go index 3310560..ca9b2c0 100644 --- a/cmd/tfstated/get.go +++ b/pkg/backend/get.go @@ -1,4 +1,4 @@ -package main +package backend import ( "fmt" diff --git a/pkg/backend/healthz.go b/pkg/backend/healthz.go new file mode 100644 index 0000000..70ece68 --- /dev/null +++ b/pkg/backend/healthz.go @@ -0,0 +1,12 @@ +package backend + +import "net/http" + +func handleHealthz() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + }) +} diff --git a/cmd/tfstated/lock.go b/pkg/backend/lock.go index 80e3575..ef62198 100644 --- a/cmd/tfstated/lock.go +++ b/pkg/backend/lock.go @@ -1,4 +1,4 @@ -package main +package backend import ( "fmt" diff --git a/cmd/tfstated/post.go b/pkg/backend/post.go index 86344b1..8271022 100644 --- a/cmd/tfstated/post.go +++ b/pkg/backend/post.go @@ -1,4 +1,4 @@ -package main +package backend import ( "fmt" diff --git a/cmd/tfstated/routes.go b/pkg/backend/routes.go index 019bb76..960a2e8 100644 --- a/cmd/tfstated/routes.go +++ b/pkg/backend/routes.go @@ -1,10 +1,10 @@ -package main +package backend import ( "net/http" - "git.adyxax.org/adyxax/tfstated/pkg/basic_auth" "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/middlewares/basic_auth" ) func addRoutes( diff --git a/pkg/backend/run.go b/pkg/backend/run.go new file mode 100644 index 0000000..d76d189 --- /dev/null +++ b/pkg/backend/run.go @@ -0,0 +1,45 @@ +package backend + +import ( + "context" + "log/slog" + "net" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger" +) + +func Run( + ctx context.Context, + db *database.DB, + getenv func(string) string, +) *http.Server { + mux := http.NewServeMux() + addRoutes( + mux, + db, + ) + + host := getenv("TFSTATED_HOST") + if host == "" { + host = "127.0.0.1" + } + port := getenv("TFSTATED_PORT") + if port == "" { + port = "8080" + } + + httpServer := &http.Server{ + Addr: net.JoinHostPort(host, port), + Handler: logger.Middleware(mux, false), + } + go func() { + slog.Info("backend http server listening", "address", httpServer.Addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("error listening and serving backend http server", "address", httpServer.Addr, "error", err) + } + }() + + return httpServer +} diff --git a/cmd/tfstated/unlock.go b/pkg/backend/unlock.go index c003d8d..bc601f0 100644 --- a/cmd/tfstated/unlock.go +++ b/pkg/backend/unlock.go @@ -1,4 +1,4 @@ -package main +package backend import ( "fmt" diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go index f506155..2c1dc6d 100644 --- a/pkg/database/accounts.go +++ b/pkg/database/accounts.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -17,6 +18,99 @@ var AdvertiseAdminPassword = func(password string) { slog.Info("Generated an initial admin password, please change it or delete the admin account after your first login", "password", password) } +func (db *DB) InitAdminAccount() error { + return db.WithTransaction(func(tx *sql.Tx) error { + var hasAdminAccount bool + if err := tx.QueryRowContext(db.ctx, `SELECT EXISTS (SELECT 1 FROM accounts WHERE is_admin);`).Scan(&hasAdminAccount); err != nil { + return fmt.Errorf("failed to select if there is an admin account in the database: %w", err) + } + if !hasAdminAccount { + var accountId uuid.UUID + if err := accountId.Generate(uuid.V7); err != nil { + return fmt.Errorf("failed to generate account id: %w", err) + } + var password uuid.UUID + if err := password.Generate(uuid.V4); err != nil { + return fmt.Errorf("failed to generate initial admin password: %w", err) + } + salt := helpers.GenerateSalt() + hash := helpers.HashPassword(password.String(), salt) + if _, err := tx.ExecContext(db.ctx, + `INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings) + VALUES (:id, "admin", :salt, :hash, TRUE, :settings) + ON CONFLICT DO UPDATE SET password_hash = :hash + WHERE username = "admin";`, + sql.Named("id", accountId), + sql.Named("hash", hash), + sql.Named("salt", salt), + sql.Named("settings", []byte("{}")), + ); err == nil { + AdvertiseAdminPassword(password.String()) + } else { + return fmt.Errorf("failed to set initial admin password: %w", err) + } + } + return nil + }) +} + +func (db *DB) LoadAccountUsernames() (map[string]string, error) { + rows, err := db.Query( + `SELECT id, username FROM accounts;`) + if err != nil { + return nil, fmt.Errorf("failed to load accounts from database: %w", err) + } + defer rows.Close() + accounts := make(map[string]string) + for rows.Next() { + var ( + id string + username string + ) + err = rows.Scan(&id, &username) + if err != nil { + return nil, fmt.Errorf("failed to load account from row: %w", err) + } + accounts[id] = username + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to load accounts from rows: %w", err) + } + return accounts, nil +} + +func (db *DB) LoadAccountById(id string) (*model.Account, error) { + account := model.Account{ + Id: id, + } + var ( + created int64 + lastLogin int64 + ) + err := db.QueryRow( + `SELECT username, salt, password_hash, is_admin, created, last_login, settings + FROM accounts + WHERE id = ?;`, + id, + ).Scan(&account.Username, + &account.Salt, + &account.PasswordHash, + &account.IsAdmin, + &created, + &lastLogin, + &account.Settings, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load account by id %s: %w", id, err) + } + account.Created = time.Unix(created, 0) + account.LastLogin = time.Unix(lastLogin, 0) + return &account, nil +} + func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { account := model.Account{ Username: username, @@ -42,50 +136,31 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, err + return nil, fmt.Errorf("failed to load account by username %s: %w", username, err) } account.Created = time.Unix(created, 0) account.LastLogin = time.Unix(lastLogin, 0) return &account, nil } -func (db *DB) InitAdminAccount() error { - tx, err := db.Begin() +func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error { + data, err := json.Marshal(settings) if err != nil { - return err + return fmt.Errorf("failed to marshal settings for user %s: %w", account.Username, err) } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - var hasAdminAccount bool - if err = tx.QueryRowContext(db.ctx, `SELECT EXISTS (SELECT 1 FROM accounts WHERE is_admin);`).Scan(&hasAdminAccount); err != nil { - return fmt.Errorf("failed to select if there is an admin account in the database: %w", err) + _, err = db.Exec(`UPDATE accounts SET settings = ? WHERE id = ?`, data, account.Id) + if err != nil { + return fmt.Errorf("failed to update settings for user %s: %w", account.Username, err) } - if hasAdminAccount { - tx.Rollback() - } else { - var password uuid.UUID - if err = password.Generate(uuid.V4); err != nil { - return fmt.Errorf("failed to generate initial admin password: %w", err) - } - salt := helpers.GenerateSalt() - hash := helpers.HashPassword(password.String(), salt) - if _, err = tx.ExecContext(db.ctx, - `INSERT INTO accounts(username, salt, password_hash, is_admin) - VALUES ("admin", :salt, :hash, TRUE) - ON CONFLICT DO UPDATE SET password_hash = :hash - WHERE username = "admin";`, - sql.Named("salt", salt), - sql.Named("hash", hash), - ); err != nil { - return fmt.Errorf("failed to set initial admin password: %w", err) - } - err = tx.Commit() - if err == nil { - AdvertiseAdminPassword(password.String()) - } + return nil +} + +func (db *DB) TouchAccount(account *model.Account) error { + now := time.Now().UTC() + _, err := db.Exec(`UPDATE accounts SET last_login = ? WHERE id = ?`, now.Unix(), account.Id) + if err != nil { + return fmt.Errorf("failed to update last_login for user %s: %w", account.Username, err) } - return err + account.LastLogin = now + return nil } diff --git a/pkg/database/db.go b/pkg/database/db.go index 38265ba..eac31eb 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -3,7 +3,9 @@ package database import ( "context" "database/sql" + "fmt" "runtime" + "strconv" "git.adyxax.org/adyxax/tfstated/pkg/scrypto" ) @@ -11,7 +13,7 @@ import ( func initDB(ctx context.Context, url string) (*sql.DB, error) { db, err := sql.Open("sqlite3", url) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to open database: %w", err) } defer func() { if err != nil { @@ -19,7 +21,7 @@ func initDB(ctx context.Context, url string) (*sql.DB, error) { } }() if _, err = db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil { - return nil, err + return nil, fmt.Errorf("failed to set pragma: %w", err) } return db, nil @@ -33,10 +35,10 @@ type DB struct { writeDB *sql.DB } -func NewDB(ctx context.Context, url string) (*DB, error) { +func NewDB(ctx context.Context, url string, getenv func(string) string) (*DB, error) { readDB, err := initDB(ctx, url) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to init read database connection: %w", err) } defer func() { if err != nil { @@ -47,7 +49,7 @@ func NewDB(ctx context.Context, url string) (*DB, error) { writeDB, err := initDB(ctx, url) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to init write database connection: %w", err) } defer func() { if err != nil { @@ -62,32 +64,45 @@ func NewDB(ctx context.Context, url string) (*DB, error) { versionsHistoryLimit: 64, writeDB: writeDB, } - if _, err = db.Exec("PRAGMA foreign_keys = ON"); err != nil { - return nil, err + pragmas := []struct { + key string + value string + }{ + {"foreign_keys", "ON"}, + {"cache_size", "10000000"}, + {"journal_mode", "WAL"}, + {"synchronous", "NORMAL"}, } - if _, err = db.Exec("PRAGMA cache_size = 10000000"); err != nil { - return nil, err + for _, pragma := range pragmas { + if _, err = db.Exec(fmt.Sprintf("PRAGMA %s = %s", pragma.key, pragma.value)); err != nil { + return nil, fmt.Errorf("failed to set pragma: %w", err) + } } - if _, err = db.Exec("PRAGMA journal_mode = WAL"); err != nil { - return nil, err + if err = db.migrate(); err != nil { + return nil, fmt.Errorf("failed to migrate: %w", err) } - if _, err = db.Exec("PRAGMA synchronous = NORMAL"); err != nil { - return nil, err + + dataEncryptionKey := getenv("DATA_ENCRYPTION_KEY") + if dataEncryptionKey == "" { + return nil, fmt.Errorf("the DATA_ENCRYPTION_KEY environment variable is not set") } - if err = db.migrate(); err != nil { - return nil, err + if err := db.dataEncryptionKey.FromBase64(dataEncryptionKey); err != nil { + return nil, fmt.Errorf("failed to decode the DATA_ENCRYPTION_KEY environment variable, expected base64: %w", err) + } + versionsHistoryLimit := getenv("VERSIONS_HISTORY_LIMIT") + if versionsHistoryLimit != "" { + if db.versionsHistoryLimit, err = strconv.Atoi(versionsHistoryLimit); err != nil { + return nil, fmt.Errorf("failed to parse the VERSIONS_HISTORY_LIMIT environment variable, expected an integer: %w", err) + } } return &db, nil } -func (db *DB) Begin() (*sql.Tx, error) { - return db.writeDB.Begin() -} - func (db *DB) Close() error { if err := db.readDB.Close(); err != nil { _ = db.writeDB.Close() + return fmt.Errorf("failed to close read database connection: %w", err) } return db.writeDB.Close() } @@ -96,14 +111,32 @@ func (db *DB) Exec(query string, args ...any) (sql.Result, error) { return db.writeDB.ExecContext(db.ctx, query, args...) } -func (db *DB) QueryRow(query string, args ...any) *sql.Row { - return db.readDB.QueryRowContext(db.ctx, query, args...) +func (db *DB) Query(query string, args ...any) (*sql.Rows, error) { + return db.readDB.QueryContext(db.ctx, query, args...) } -func (db *DB) SetDataEncryptionKey(s string) error { - return db.dataEncryptionKey.FromBase64(s) +func (db *DB) QueryRow(query string, args ...any) *sql.Row { + return db.readDB.QueryRowContext(db.ctx, query, args...) } -func (db *DB) SetVersionsHistoryLimit(n int) { - db.versionsHistoryLimit = n +func (db *DB) WithTransaction(f func(tx *sql.Tx) error) error { + tx, err := db.writeDB.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + if err2 := tx.Rollback(); err2 != nil { + panic(fmt.Sprintf("failed to rollback transaction: %+v. Reason for rollback: %+v", err2, err)) + } + } + }() + if err = f(tx); err != nil { + return fmt.Errorf("failed to execute function inside transaction: %w", err) + } else { + if err = tx.Commit(); err != nil { + err = fmt.Errorf("failed to commit transaction: %w", err) + } + } + return err } diff --git a/pkg/database/locks.go b/pkg/database/locks.go index 6951337..e78fd0c 100644 --- a/pkg/database/locks.go +++ b/pkg/database/locks.go @@ -10,45 +10,32 @@ import ( // true if the function locked the state, otherwise returns false and the lock // parameter is updated to the value of the existing lock func (db *DB) SetLockOrGetExistingLock(path string, lock any) (bool, error) { - tx, err := db.Begin() - if err != nil { - return false, err - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - var lockData []byte - if err = tx.QueryRowContext(db.ctx, `SELECT lock FROM states WHERE path = ?;`, path).Scan(&lockData); err != nil { - if errors.Is(err, sql.ErrNoRows) { - if lockData, err = json.Marshal(lock); err != nil { - return false, err + ret := false + return ret, db.WithTransaction(func(tx *sql.Tx) error { + var lockData []byte + if err := tx.QueryRowContext(db.ctx, `SELECT lock FROM states WHERE path = ?;`, path).Scan(&lockData); err != nil { + if errors.Is(err, sql.ErrNoRows) { + if lockData, err = json.Marshal(lock); err != nil { + return err + } + _, err = tx.ExecContext(db.ctx, `INSERT INTO states(path, lock) VALUES (?, json(?))`, path, lockData) + ret = true + return err + } else { + return err } - _, err = tx.ExecContext(db.ctx, `INSERT INTO states(path, lock) VALUES (?, json(?))`, path, lockData) - if err != nil { - return false, err - } - err = tx.Commit() - return true, err - } else { - return false, err } - } - if lockData != nil { - _ = tx.Rollback() - err = json.Unmarshal(lockData, lock) - return false, err - } - if lockData, err = json.Marshal(lock); err != nil { - return false, err - } - _, err = tx.ExecContext(db.ctx, `UPDATE states SET lock = json(?) WHERE path = ?;`, lockData, path) - if err != nil { - return false, err - } - err = tx.Commit() - return true, err + if lockData != nil { + return json.Unmarshal(lockData, lock) + } + var err error + if lockData, err = json.Marshal(lock); err != nil { + return err + } + _, err = tx.ExecContext(db.ctx, `UPDATE states SET lock = json(?) WHERE path = ?;`, lockData, path) + ret = true + return err + }) } func (db *DB) Unlock(path, lock any) (bool, error) { diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index b460884..31cc3d3 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -1,6 +1,7 @@ package database import ( + "database/sql" "embed" "io/fs" @@ -28,36 +29,23 @@ func (db *DB) migrate() error { return err } - tx, err := db.Begin() - if err != nil { - return err - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - var version int - if err = tx.QueryRowContext(db.ctx, `SELECT version FROM schema_version;`).Scan(&version); err != nil { - if err.Error() == "no such table: schema_version" { - version = 0 - } else { - return err + return db.WithTransaction(func(tx *sql.Tx) error { + var version int + if err = tx.QueryRowContext(db.ctx, `SELECT version FROM schema_version;`).Scan(&version); err != nil { + if err.Error() == "no such table: schema_version" { + version = 0 + } else { + return err + } } - } - for version < len(statements) { - if _, err = tx.ExecContext(db.ctx, statements[version]); err != nil { - return err + for version < len(statements) { + if _, err = tx.ExecContext(db.ctx, statements[version]); err != nil { + return err + } + version++ } - version++ - } - if _, err = tx.ExecContext(db.ctx, `DELETE FROM schema_version; INSERT INTO schema_version (version) VALUES (?);`, version); err != nil { + _, err = tx.ExecContext(db.ctx, `DELETE FROM schema_version; INSERT INTO schema_version (version) VALUES (?);`, version) return err - } - if err = tx.Commit(); err != nil { - return err - } - return nil + }) } diff --git a/pkg/database/sessions.go b/pkg/database/sessions.go new file mode 100644 index 0000000..43f9d50 --- /dev/null +++ b/pkg/database/sessions.go @@ -0,0 +1,77 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.adyxax.org/adyxax/tfstated/pkg/model" + "go.n16f.net/uuid" +) + +func (db *DB) CreateSession(account *model.Account) (string, error) { + var sessionId uuid.UUID + if err := sessionId.Generate(uuid.V4); err != nil { + return "", fmt.Errorf("failed to generate session id: %w", err) + } + if _, err := db.Exec( + `INSERT INTO sessions(id, account_id, data) + VALUES (?, ?, ?);`, + sessionId.String(), + account.Id, + "", + ); err != nil { + return "", fmt.Errorf("failed insert new session in database: %w", err) + } + return sessionId.String(), nil +} + +func (db *DB) DeleteSession(session *model.Session) error { + _, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, session.Id) + if err != nil { + return fmt.Errorf("failed to delete session %s: %w", session.Id, err) + } + return nil +} + +func (db *DB) LoadSessionById(id string) (*model.Session, error) { + session := model.Session{ + Id: id, + } + var ( + created int64 + updated int64 + ) + err := db.QueryRow( + `SELECT account_id, + created, + updated, + data + FROM sessions + WHERE id = ?;`, + id, + ).Scan(&session.AccountId, + &created, + &updated, + &session.Data, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load session by id %s: %w", id, err) + } + session.Created = time.Unix(created, 0) + session.Updated = time.Unix(updated, 0) + return &session, nil +} + +func (db *DB) TouchSession(sessionId string) error { + now := time.Now().UTC() + _, err := db.Exec(`UPDATE sessions SET updated = ? WHERE id = ?`, now.Unix(), sessionId) + if err != nil { + return fmt.Errorf("failed to touch updated for session %s: %w", sessionId, err) + } + return nil +} diff --git a/pkg/database/sql/000_init.sql b/pkg/database/sql/000_init.sql index b635442..3530e52 100644 --- a/pkg/database/sql/000_init.sql +++ b/pkg/database/sql/000_init.sql @@ -3,27 +3,38 @@ CREATE TABLE schema_version ( ) STRICT; CREATE TABLE accounts ( - id INTEGER PRIMARY KEY, + id TEXT PRIMARY KEY, username TEXT NOT NULL, salt BLOB NOT NULL, password_hash BLOB NOT NULL, is_admin INTEGER NOT NULL DEFAULT FALSE, created INTEGER NOT NULL DEFAULT (unixepoch()), last_login INTEGER NOT NULL DEFAULT (unixepoch()), - settings TEXT + settings BLOB NOT NULL ) STRICT; CREATE UNIQUE INDEX accounts_username on accounts(username); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + created INTEGER NOT NULL DEFAULT (unixepoch()), + updated INTEGER NOT NULL DEFAULT (unixepoch()), + data TEXT NOT NULL, + FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE +) STRICT; + CREATE TABLE states ( id INTEGER PRIMARY KEY, path TEXT NOT NULL, - lock TEXT + lock TEXT, + created INTEGER DEFAULT (unixepoch()), + updated INTEGER DEFAULT (unixepoch()) ) STRICT; CREATE UNIQUE INDEX states_path on states(path); CREATE TABLE versions ( id INTEGER PRIMARY KEY, - account_id INTEGER NOT NULL, + account_id TEXT NOT NULL, state_id INTEGER, data BLOB, lock TEXT, diff --git a/pkg/database/states.go b/pkg/database/states.go index 4f0ce58..75af2e5 100644 --- a/pkg/database/states.go +++ b/pkg/database/states.go @@ -5,8 +5,55 @@ import ( "errors" "fmt" "slices" + "time" + + "git.adyxax.org/adyxax/tfstated/pkg/model" + "github.com/mattn/go-sqlite3" ) +func (db *DB) CreateState(path string, accountId string, data []byte) (*model.Version, error) { + encryptedData, err := db.dataEncryptionKey.EncryptAES256(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt state data: %w", err) + } + version := &model.Version{ + AccountId: accountId, + } + return version, db.WithTransaction(func(tx *sql.Tx) error { + result, err := tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path) + if err != nil { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + if sqliteErr.Code == sqlite3.ErrNo(sqlite3.ErrConstraint) { + version = nil + return nil + } + } + return fmt.Errorf("failed to insert new state: %w", err) + } + stateId, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id for new state: %w", err) + } + version.StateId = int(stateId) + result, err = tx.ExecContext(db.ctx, + `INSERT INTO versions(account_id, data, state_id) + VALUES (:accountID, :data, :stateID)`, + sql.Named("accountID", accountId), + sql.Named("data", encryptedData), + sql.Named("stateID", stateId)) + if err != nil { + return fmt.Errorf("failed to insert new state version: %w", err) + } + versionId, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id for new version of the state: %w", err) + } + version.Id = int(versionId) + return nil + }) +} + // returns true in case of successful deletion func (db *DB) DeleteState(path string) (bool, error) { result, err := db.Exec(`DELETE FROM states WHERE path = ?;`, path) @@ -42,58 +89,109 @@ func (db *DB) GetState(path string) ([]byte, error) { return db.dataEncryptionKey.DecryptAES256(encryptedData) } -// returns true in case of id mismatch -func (db *DB) SetState(path string, accountID int, data []byte, lockID string) (bool, error) { - encryptedData, err := db.dataEncryptionKey.EncryptAES256(data) +func (db *DB) LoadStateById(stateId int) (*model.State, error) { + state := model.State{ + Id: stateId, + } + var ( + created int64 + updated int64 + ) + err := db.QueryRow( + `SELECT created, lock, path, updated FROM states WHERE id = ?;`, + stateId).Scan(&created, &state.Lock, &state.Path, &updated) if err != nil { - return false, err + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load state id %d from database: %w", stateId, err) } - tx, err := db.Begin() + state.Created = time.Unix(created, 0) + state.Updated = time.Unix(updated, 0) + return &state, nil +} + +func (db *DB) LoadStates() ([]model.State, error) { + rows, err := db.Query( + `SELECT created, id, lock, path, updated FROM states;`) if err != nil { - return false, err + return nil, fmt.Errorf("failed to load states from database: %w", err) } - defer func() { + defer rows.Close() + states := make([]model.State, 0) + for rows.Next() { + var state model.State + var ( + created int64 + updated int64 + ) + err = rows.Scan(&created, &state.Id, &state.Lock, &state.Path, &updated) if err != nil { - _ = tx.Rollback() - } - }() - var ( - stateID int64 - lockData []byte - ) - if err = tx.QueryRowContext(db.ctx, `SELECT id, lock->>'ID' FROM states WHERE path = ?;`, path).Scan(&stateID, &lockData); err != nil { - if errors.Is(err, sql.ErrNoRows) { - var result sql.Result - result, err = tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path) - if err != nil { - return false, err - } - stateID, err = result.LastInsertId() - if err != nil { - return false, err - } - } else { - return false, err + return nil, fmt.Errorf("failed to load state from row: %w", err) } + state.Created = time.Unix(created, 0) + state.Updated = time.Unix(updated, 0) + states = append(states, state) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to load states from rows: %w", err) + } + return states, nil +} - if lockID != "" && slices.Compare([]byte(lockID), lockData) != 0 { - err = fmt.Errorf("failed to update state, lock ID does not match") - return true, err +// returns true in case of id mismatch +func (db *DB) SetState(path string, accountID string, data []byte, lockID string) (bool, error) { + encryptedData, err := db.dataEncryptionKey.EncryptAES256(data) + if err != nil { + return false, fmt.Errorf("failed to encrypt state data: %w", err) } - _, err = tx.ExecContext(db.ctx, - `INSERT INTO versions(account_id, state_id, data, lock) + ret := false + return ret, db.WithTransaction(func(tx *sql.Tx) error { + var ( + stateID int64 + lockData []byte + ) + if err = tx.QueryRowContext(db.ctx, `SELECT id, lock->>'ID' FROM states WHERE path = ?;`, path).Scan(&stateID, &lockData); err != nil { + if errors.Is(err, sql.ErrNoRows) { + var result sql.Result + result, err = tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path) + if err != nil { + return fmt.Errorf("failed to insert new state: %w", err) + } + stateID, err = result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id for new state: %w", err) + } + } else { + return err + } + } + + if lockID != "" && slices.Compare([]byte(lockID), lockData) != 0 { + err = fmt.Errorf("failed to update state, lock ID does not match") + ret = true + return err + } + _, err = tx.ExecContext(db.ctx, + `INSERT INTO versions(account_id, state_id, data, lock) SELECT :accountID, :stateID, :data, lock FROM states WHERE states.id = :stateID;`, - sql.Named("accountID", accountID), - sql.Named("stateID", stateID), - sql.Named("data", encryptedData)) - if err != nil { - return false, err - } - _, err = tx.ExecContext(db.ctx, - `DELETE FROM versions + sql.Named("accountID", accountID), + sql.Named("stateID", stateID), + sql.Named("data", encryptedData)) + if err != nil { + return fmt.Errorf("failed to insert new state version: %w", err) + } + _, err = tx.ExecContext(db.ctx, + `UPDATE states SET updated = ? WHERE id = ?;`, + time.Now().UTC().Unix(), + stateID) + if err != nil { + return fmt.Errorf("failed to touch updated for state: %w", err) + } + _, err = tx.ExecContext(db.ctx, + `DELETE FROM versions WHERE state_id = (SELECT id FROM states WHERE path = :path) @@ -104,12 +202,9 @@ func (db *DB) SetState(path string, accountID int, data []byte, lockID string) ( WHERE states.path = :path ORDER BY versions.id DESC LIMIT :limit));`, - sql.Named("limit", db.versionsHistoryLimit), - sql.Named("path", path), - ) - if err != nil { - return false, err - } - err = tx.Commit() - return false, err + sql.Named("limit", db.versionsHistoryLimit), + sql.Named("path", path), + ) + return err + }) } diff --git a/pkg/database/versions.go b/pkg/database/versions.go new file mode 100644 index 0000000..f05d230 --- /dev/null +++ b/pkg/database/versions.go @@ -0,0 +1,67 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +func (db *DB) LoadVersionById(id int) (*model.Version, error) { + version := model.Version{ + Id: id, + } + var ( + created int64 + encryptedData []byte + ) + err := db.QueryRow( + `SELECT account_id, state_id, data, lock, created FROM versions WHERE id = ?;`, + id).Scan( + &version.AccountId, + &version.StateId, + &encryptedData, + &version.Lock, + &created) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load version id %d from database: %w", id, err) + } + version.Created = time.Unix(created, 0) + version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData) + if err != nil { + return nil, fmt.Errorf("failed to decrypt version %d data: %w", id, err) + } + return &version, nil +} + +func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) { + rows, err := db.Query( + `SELECT account_id, created, data, id, lock + FROM versions + WHERE state_id = ? + ORDER BY id DESC;`, state.Id) + if err != nil { + return nil, fmt.Errorf("failed to load versions from database: %w", err) + } + defer rows.Close() + versions := make([]model.Version, 0) + for rows.Next() { + var version model.Version + var created int64 + err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &version.Lock) + if err != nil { + return nil, fmt.Errorf("failed to load version from row: %w", err) + } + version.Created = time.Unix(created, 0) + versions = append(versions, version) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to load versions from rows: %w", err) + } + return versions, nil +} diff --git a/pkg/basic_auth/middleware.go b/pkg/middlewares/basic_auth/middleware.go index 0e22ad3..cb2dcf0 100644 --- a/pkg/basic_auth/middleware.go +++ b/pkg/middlewares/basic_auth/middleware.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "time" "git.adyxax.org/adyxax/tfstated/pkg/database" "git.adyxax.org/adyxax/tfstated/pkg/helpers" @@ -29,9 +28,7 @@ func Middleware(db *database.DB) func(http.Handler) http.Handler { helpers.ErrorResponse(w, http.StatusForbidden, fmt.Errorf("Forbidden")) return } - now := time.Now().UTC() - _, err = db.Exec(`UPDATE accounts SET last_login = ? WHERE id = ?`, now.Unix(), account.Id) - if err != nil { + if err := db.TouchAccount(account); err != nil { helpers.ErrorResponse(w, http.StatusInternalServerError, err) return } diff --git a/pkg/logger/body_writer.go b/pkg/middlewares/logger/body_writer.go index f57a03e..f57a03e 100644 --- a/pkg/logger/body_writer.go +++ b/pkg/middlewares/logger/body_writer.go diff --git a/pkg/logger/middleware.go b/pkg/middlewares/logger/middleware.go index 54cca96..54cca96 100644 --- a/pkg/logger/middleware.go +++ b/pkg/middlewares/logger/middleware.go diff --git a/pkg/model/account.go b/pkg/model/account.go index 7a69685..d47668a 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -2,6 +2,7 @@ package model import ( "crypto/subtle" + "encoding/json" "time" "git.adyxax.org/adyxax/tfstated/pkg/helpers" @@ -10,14 +11,14 @@ import ( type AccountContextKey struct{} type Account struct { - Id int + Id string Username string Salt []byte PasswordHash []byte IsAdmin bool Created time.Time LastLogin time.Time - Settings any + Settings json.RawMessage } func (account *Account) CheckPassword(password string) bool { diff --git a/pkg/model/session.go b/pkg/model/session.go new file mode 100644 index 0000000..8b2bb01 --- /dev/null +++ b/pkg/model/session.go @@ -0,0 +1,20 @@ +package model + +import ( + "time" +) + +type SessionContextKey struct{} + +type Session struct { + Id string + AccountId string + Created time.Time + Updated time.Time + Data any +} + +func (session *Session) IsExpired() bool { + // TODO + return false +} diff --git a/pkg/model/settings.go b/pkg/model/settings.go new file mode 100644 index 0000000..9da655b --- /dev/null +++ b/pkg/model/settings.go @@ -0,0 +1,7 @@ +package model + +type SettingsContextKey struct{} + +type Settings struct { + LightMode bool `json:"light_mode"` +} diff --git a/pkg/model/state.go b/pkg/model/state.go new file mode 100644 index 0000000..8e1c277 --- /dev/null +++ b/pkg/model/state.go @@ -0,0 +1,13 @@ +package model + +import ( + "time" +) + +type State struct { + Created time.Time + Id int + Lock *string + Path string + Updated time.Time +} diff --git a/pkg/model/version.go b/pkg/model/version.go new file mode 100644 index 0000000..f07db45 --- /dev/null +++ b/pkg/model/version.go @@ -0,0 +1,15 @@ +package model + +import ( + "encoding/json" + "time" +) + +type Version struct { + AccountId string + Created time.Time + Data json.RawMessage + Id int + Lock *string + StateId int +} diff --git a/pkg/webui/cache.go b/pkg/webui/cache.go new file mode 100644 index 0000000..cef999b --- /dev/null +++ b/pkg/webui/cache.go @@ -0,0 +1,10 @@ +package webui + +import "net/http" + +func cache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/webui/error.go b/pkg/webui/error.go new file mode 100644 index 0000000..afce9a6 --- /dev/null +++ b/pkg/webui/error.go @@ -0,0 +1,23 @@ +package webui + +import ( + "html/template" + "net/http" +) + +var errorTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/error.html")) + +func errorResponse(w http.ResponseWriter, status int, err error) { + type ErrorData struct { + Page + Err error + Status int + StatusText string + } + render(w, errorTemplates, status, &ErrorData{ + Page: Page{Title: "Error", Section: "error"}, + Err: err, + Status: status, + StatusText: http.StatusText(status), + }) +} diff --git a/cmd/tfstated/healthz.go b/pkg/webui/healthz.go index 20c72c9..dee51d0 100644 --- a/cmd/tfstated/healthz.go +++ b/pkg/webui/healthz.go @@ -1,4 +1,4 @@ -package main +package webui import "net/http" diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html new file mode 100644 index 0000000..4ec6565 --- /dev/null +++ b/pkg/webui/html/base.html @@ -0,0 +1,58 @@ +{{ define "nav" }} +<header> + <nav> + <a href="/"> + <h6>TFSTATED</h6> + </a> + </nav> +</header> +{{ if eq .Page.Section "login" }} +<a href="/login" class="active"> + <i>login</i> + <span>Login</span> +</a> +{{ else }} +<a href="/states"{{ if eq .Page.Section "states" }} class="fill"{{ end}}> + <i>home_storage</i> + <span>States</span> +</a> +<a href="/settings"{{ if eq .Page.Section "settings" }} class="fill"{{ end}}> + <i>settings</i> + <span>Settings</span> +</a> +<a href="/logout"> + <i>logout</i> + <span>Logout</span> +</a> +{{ end }} +{{ end }} +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="icon" href="/static/favicon.svg"> + <link href="/static/main.css" rel="stylesheet"> + <link href="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.css" rel="stylesheet"> + <title>TFSTATED - {{ .Page.Title }}</title> + </head> + <body class="{{ if .Page.LightMode }}light{{ else }}dark{{ end }}"> + <nav class="left drawer l">{{ template "nav" . }}</nav> + <nav class="left m">{{ template "nav" . }}</nav> + <nav class="bottom s">{{ template "nav" . }}</nav> + <header> + <nav> + {{ if ne .Page.Precedent "" }} + <a href="{{ .Page.Precedent }}" class="button circle chip"> + <i>arrow_back</i> + </a> + {{ end }} + <h5 class="max center-align">{{ .Page.Title }}</h5> + </nav> + </header> + {{ template "main" . }} + <footer> + </footer> + <script type="module" src="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.js"></script> + </body> +</html> diff --git a/pkg/webui/html/error.html b/pkg/webui/html/error.html new file mode 100644 index 0000000..cda21f6 --- /dev/null +++ b/pkg/webui/html/error.html @@ -0,0 +1,6 @@ +{{ define "main" }} +<main class="responsive"> +<h5>{{ .Status }} - {{ .StatusText }}</h5> +<p>{{ .Err }}</p> +</main> +{{ end }} diff --git a/pkg/webui/html/login.html b/pkg/webui/html/login.html new file mode 100644 index 0000000..deb6d4a --- /dev/null +++ b/pkg/webui/html/login.html @@ -0,0 +1,27 @@ +{{ define "main" }} +<main class="responsive"> + <form action="/login" method="post"> + <fieldset> + <div class="field border label{{ if .Forbidden }} invalid{{ end}}"> + <input autofocus + id="username" + name="username" + type="text" + value="{{ .Username }}" + required> + <label for="username">Username</label> + {{ if .Forbidden }}<span class="error">Invalid username or password</span>{{ end }} + </div> + <div class="field border label{{ if .Forbidden }} invalid{{ end}}"> + <input id="password" + name="password" + type="password" + required> + <label for="password">Password</label> + {{ if .Forbidden }}<span class="error">Invalid username or password</span>{{ end }} + </div> + <button class="small-round" type="submit" value="login">Login</button> + </fieldset> + </form> +</main> +{{ end }} diff --git a/pkg/webui/html/logout.html b/pkg/webui/html/logout.html new file mode 100644 index 0000000..e9203d4 --- /dev/null +++ b/pkg/webui/html/logout.html @@ -0,0 +1,5 @@ +{{ define "main" }} +<main class="responsive"> + <h5>Logout successful</h5> +</main> +{{ end }} diff --git a/pkg/webui/html/settings.html b/pkg/webui/html/settings.html new file mode 100644 index 0000000..4040b9b --- /dev/null +++ b/pkg/webui/html/settings.html @@ -0,0 +1,25 @@ +{{ define "main" }} +<main class="responsive"> + <form action="/settings" method="post"> + <fieldset> + <div class="field middle-align"> + <nav> + <div class="max"> + <h6>Dark Mode</h6> + </div> + <label class="switch icon"> + <input {{ if not .Settings.LightMode }} checked{{ end }} + name="dark-mode" + type="checkbox" + value="1" /> + <span> + <i>dark_mode</i> + </span> + </label> + </nav> + </div> + <button class="small-round" type="submit" value="login">Save</button> + </fieldset> + </form> +</main> +{{ end }} diff --git a/pkg/webui/html/state.html b/pkg/webui/html/state.html new file mode 100644 index 0000000..4439d9e --- /dev/null +++ b/pkg/webui/html/state.html @@ -0,0 +1,30 @@ +{{ define "main" }} +<main class="responsive" id="main"> + <p> + Locked: + {{ if eq .State.Lock nil }}no{{ else }} + <span>yes</span> + <div class="tooltip left max"> + <b>Lock</b> + <p>{{ .State.Lock }}</p> + </div> + {{ end }} + </p> + <table class="clickable-rows no-space"> + <thead> + <tr> + <th>By</th> + <th>Created</th> + </tr> + </thead> + <tbody> + {{ range .Versions }} + <tr> + <td><a href="/version/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td> + <td><a href="/version/{{ .Id }}">{{ .Created }}</a></td> + </tr> + {{ end }} + </tbody> + </table> +</main> +{{ end }} diff --git a/pkg/webui/html/states.html b/pkg/webui/html/states.html new file mode 100644 index 0000000..37d80cf --- /dev/null +++ b/pkg/webui/html/states.html @@ -0,0 +1,38 @@ +{{ define "main" }} +<main class="responsive" id="main"> + <a href="/states/new"> + <button class="small-round"> + <i>add</i> + <span>New</span> + </button> + </a> + <table class="clickable-rows no-space"> + <thead> + <tr> + <th>Path</th> + <th>Updated</th> + <th>Locked</th> + </tr> + </thead> + <tbody> + {{ range .States }} + <tr> + <td><a href="/state/{{ .Id }}">{{ .Path }}</a></td> + <td><a href="/state/{{ .Id }}">{{ .Updated }}</a></td> + <td> + <a href="/state/{{ .Id }}"> + {{ if eq .Lock nil }}no{{ else }} + <span>yes</span> + <div class="tooltip left max"> + <b>Lock</b> + <p>{{ .Lock }}</p> + </div> + {{ end }} + </a> + </td> + </tr> + {{ end }} + </tbody> + </table> +</main> +{{ end }} diff --git a/pkg/webui/html/states_new.html b/pkg/webui/html/states_new.html new file mode 100644 index 0000000..68facc7 --- /dev/null +++ b/pkg/webui/html/states_new.html @@ -0,0 +1,33 @@ +{{ define "main" }} +<main class="responsive"> + <form action="/states/new" enctype="multipart/form-data" method="post"> + <fieldset> + <div class="field border label{{ if .PathError }} invalid{{ end }}"> + <input autofocus + id="path" + name="path" + required + type="text" + value="{{ .Path }}"> + <label for="path">Path</label> + {{ if .PathDuplicate }} + <span class="error">This path already exist</span> + {{ else if .PathError }} + <span class="error">Invalid path</span> + {{ else }} + <span class="helper">Valid URL path beginning with a /</span> + {{ end }} + </div> + <div class="field label border"> + <input name="file" + required + type="file"> + <input type="text"> + <label>File</label> + <span class="helper">JSON state file</span> + </div> + <button class="small-round" type="submit" value="submit">New</button> + </fieldset> + </form> +</main> +{{ end }} diff --git a/pkg/webui/html/version.html b/pkg/webui/html/version.html new file mode 100644 index 0000000..b849783 --- /dev/null +++ b/pkg/webui/html/version.html @@ -0,0 +1,10 @@ +{{ define "main" }} +<main class="responsive" id="main"> + <p> + Created by + <a href="/users/{{ .Account.Id }}">{{ .Account.Username }}</a> + at {{ .Version.Created }} + </p> + <pre>{{ .VersionData }}</pre> +</main> +{{ end }} diff --git a/pkg/webui/index.go b/pkg/webui/index.go new file mode 100644 index 0000000..1168098 --- /dev/null +++ b/pkg/webui/index.go @@ -0,0 +1,31 @@ +package webui + +import ( + "fmt" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +type Page struct { + LightMode bool + Precedent string + Section string + Title string +} + +func makePage(r *http.Request, page *Page) *Page { + settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings) + page.LightMode = settings.LightMode + return page +} + +func handleIndexGET() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.Redirect(w, r, "/states", http.StatusFound) + } else { + errorResponse(w, http.StatusNotFound, fmt.Errorf("Page not found")) + } + }) +} diff --git a/pkg/webui/login.go b/pkg/webui/login.go new file mode 100644 index 0000000..18864b2 --- /dev/null +++ b/pkg/webui/login.go @@ -0,0 +1,126 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log/slog" + "net/http" + "regexp" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var loginTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/login.html")) + +type loginPage struct { + Page + Forbidden bool + Username string +} + +func handleLoginGET() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + + session := r.Context().Value(model.SessionContextKey{}) + if session != nil { + http.Redirect(w, r, "/states", http.StatusFound) + return + } + + render(w, loginTemplate, http.StatusOK, loginPage{ + Page: Page{Title: "Login", Section: "login"}, + }) + }) +} + +func handleLoginPOST(db *database.DB) http.Handler { + var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`) + renderForbidden := func(w http.ResponseWriter, username string) { + render(w, loginTemplate, http.StatusForbidden, loginPage{ + Page: Page{Title: "Login", Section: "login"}, + Forbidden: true, + Username: username, + }) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { // the webui cannot issue this + errorResponse(w, http.StatusBadRequest, fmt.Errorf("Forbidden")) + return + } + if ok := validUsername.MatchString(username); !ok { + renderForbidden(w, username) + return + } + account, err := db.LoadAccountByUsername(username) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if account == nil || !account.CheckPassword(password) { + renderForbidden(w, username) + return + } + if err := db.TouchAccount(account); err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + sessionId, err := db.CreateSession(account) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: sessionId, + Quoted: false, + Path: "/", + MaxAge: 8 * 3600, // 1 hour sessions + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) + http.Redirect(w, r, "/", http.StatusFound) + }) +} + +func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + session := r.Context().Value(model.SessionContextKey{}) + if session == nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + account, err := db.LoadAccountById(session.(*model.Session).AccountId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if account == nil { + // this could happen if the account was deleted in the short + // time between retrieving the session and here + http.Redirect(w, r, "/login", http.StatusFound) + return + } + ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account) + var settings model.Settings + if err := json.Unmarshal(account.Settings, &settings); err != nil { + slog.Error("failed to unmarshal account settings", "err", err, "accountId", account.Id) + } + ctx = context.WithValue(ctx, model.SettingsContextKey{}, &settings) + next.ServeHTTP(w, r.WithContext(ctx)) + })) + } +} diff --git a/pkg/webui/logout.go b/pkg/webui/logout.go new file mode 100644 index 0000000..58e445d --- /dev/null +++ b/pkg/webui/logout.go @@ -0,0 +1,29 @@ +package webui + +import ( + "html/template" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var logoutTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/logout.html")) + +func handleLogoutGET(db *database.DB) http.Handler { + type logoutPage struct { + Page + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value(model.SessionContextKey{}) + err := db.DeleteSession(session.(*model.Session)) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + unsetSesssionCookie(w) + render(w, logoutTemplate, http.StatusOK, logoutPage{ + Page: Page{Title: "Logout", Section: "login"}, + }) + }) +} diff --git a/pkg/webui/render.go b/pkg/webui/render.go new file mode 100644 index 0000000..23f5e51 --- /dev/null +++ b/pkg/webui/render.go @@ -0,0 +1,22 @@ +package webui + +import ( + "bytes" + "fmt" + "html/template" + "net/http" +) + +func render(w http.ResponseWriter, t *template.Template, status int, data any) { + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf( + "%s: failed to execute template: %+v", + http.StatusText(http.StatusInternalServerError), + err))) + } else { + w.WriteHeader(status) + _, _ = buf.WriteTo(w) + } +} diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go new file mode 100644 index 0000000..7ee7841 --- /dev/null +++ b/pkg/webui/routes.go @@ -0,0 +1,28 @@ +package webui + +import ( + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" +) + +func addRoutes( + mux *http.ServeMux, + db *database.DB, +) { + requireSession := sessionsMiddleware(db) + requireLogin := loginMiddleware(db, requireSession) + mux.Handle("GET /healthz", handleHealthz()) + mux.Handle("GET /login", requireSession(handleLoginGET())) + mux.Handle("POST /login", requireSession(handleLoginPOST(db))) + mux.Handle("GET /logout", requireLogin(handleLogoutGET(db))) + mux.Handle("GET /settings", requireLogin(handleSettingsGET(db))) + mux.Handle("POST /settings", requireLogin(handleSettingsPOST(db))) + mux.Handle("GET /states", requireLogin(handleStatesGET(db))) + mux.Handle("GET /states/new", requireLogin(handleStatesNewGET(db))) + mux.Handle("POST /states/new", requireLogin(handleStatesNewPOST(db))) + mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db))) + mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS)))) + mux.Handle("GET /version/{id}", requireLogin(handleVersionGET(db))) + mux.Handle("GET /", requireLogin(handleIndexGET())) +} diff --git a/pkg/webui/run.go b/pkg/webui/run.go new file mode 100644 index 0000000..664b9e5 --- /dev/null +++ b/pkg/webui/run.go @@ -0,0 +1,52 @@ +package webui + +import ( + "context" + "embed" + "log/slog" + "net" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger" +) + +//go:embed html/* +var htmlFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +func Run( + ctx context.Context, + db *database.DB, + getenv func(string) string, +) *http.Server { + mux := http.NewServeMux() + addRoutes( + mux, + db, + ) + + host := getenv("TFSTATED_WEBUI_HOST") + if host == "" { + host = "127.0.0.1" + } + port := getenv("TFSTATED_WEBUI_PORT") + if port == "" { + port = "8081" + } + + httpServer := &http.Server{ + Addr: net.JoinHostPort(host, port), + Handler: logger.Middleware(mux, false), + } + go func() { + slog.Info("webui http server listening", "address", httpServer.Addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("error listening and serving webui http server", "address", httpServer.Addr, "error", err) + } + }() + + return httpServer +} diff --git a/pkg/webui/sessions.go b/pkg/webui/sessions.go new file mode 100644 index 0000000..7a2fd02 --- /dev/null +++ b/pkg/webui/sessions.go @@ -0,0 +1,61 @@ +package webui + +import ( + "context" + "errors" + "fmt" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +const cookieName = "tfstated" + +func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(cookieName) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + errorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to get request cookie \"%s\": %w", cookieName, err)) + return + } + if err == nil { + if len(cookie.Value) != 36 { + unsetSesssionCookie(w) + } else { + session, err := db.LoadSessionById(cookie.Value) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if session == nil { + unsetSesssionCookie(w) + } else if !session.IsExpired() { + if err := db.TouchSession(cookie.Value); err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + } + next.ServeHTTP(w, r) + }) + } +} + +func unsetSesssionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Quoted: false, + Path: "/", + MaxAge: 0, // remove invalid cookie + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) +} diff --git a/pkg/webui/settings.go b/pkg/webui/settings.go new file mode 100644 index 0000000..eb0910f --- /dev/null +++ b/pkg/webui/settings.go @@ -0,0 +1,53 @@ +package webui + +import ( + "html/template" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +type SettingsPage struct { + Page *Page + Settings *model.Settings +} + +var settingsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/settings.html")) + +func handleSettingsGET(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings) + render(w, settingsTemplates, http.StatusOK, SettingsPage{ + Page: makePage(r, &Page{Title: "Settings", Section: "settings"}), + Settings: settings, + }) + }) +} + +func handleSettingsPOST(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + darkMode := r.FormValue("dark-mode") + settings := model.Settings{ + LightMode: darkMode != "1", + } + account := r.Context().Value(model.AccountContextKey{}).(*model.Account) + err := db.SaveAccountSettings(account, &settings) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + render(w, settingsTemplates, http.StatusOK, SettingsPage{ + Page: &Page{ + LightMode: settings.LightMode, + Title: "Settings", + Section: "settings", + }, + Settings: &settings, + }) + }) +} diff --git a/pkg/webui/state.go b/pkg/webui/state.go new file mode 100644 index 0000000..2ad1597 --- /dev/null +++ b/pkg/webui/state.go @@ -0,0 +1,54 @@ +package webui + +import ( + "html/template" + "net/http" + "strconv" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var stateTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/state.html")) + +func handleStateGET(db *database.DB) http.Handler { + type StatesData struct { + Page *Page + State *model.State + Usernames map[string]string + Versions []model.Version + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stateIdStr := r.PathValue("id") + stateId, err := strconv.Atoi(stateIdStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + state, err := db.LoadStateById(stateId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + versions, err := db.LoadVersionsByState(state) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + usernames, err := db.LoadAccountUsernames() + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + render(w, stateTemplate, http.StatusOK, StatesData{ + Page: makePage(r, &Page{ + Precedent: "/states", + Section: "states", + Title: state.Path, + }), + State: state, + Usernames: usernames, + Versions: versions, + }) + }) +} diff --git a/pkg/webui/states.go b/pkg/webui/states.go new file mode 100644 index 0000000..a0d16ca --- /dev/null +++ b/pkg/webui/states.go @@ -0,0 +1,29 @@ +package webui + +import ( + "html/template" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states.html")) + +func handleStatesGET(db *database.DB) http.Handler { + type StatesData struct { + Page *Page + States []model.State + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + states, err := db.LoadStates() + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + render(w, statesTemplates, http.StatusOK, StatesData{ + Page: makePage(r, &Page{Title: "States", Section: "states"}), + States: states, + }) + }) +} diff --git a/pkg/webui/states_new.go b/pkg/webui/states_new.go new file mode 100644 index 0000000..8551191 --- /dev/null +++ b/pkg/webui/states_new.go @@ -0,0 +1,84 @@ +package webui + +import ( + "fmt" + "html/template" + "io" + "net/http" + "net/url" + "path" + "strconv" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +type StatesNewPage struct { + Page *Page + fileError bool + Path string + PathDuplicate bool + PathError bool +} + +var statesNewTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states_new.html")) + +func handleStatesNewGET(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + render(w, statesNewTemplates, http.StatusOK, StatesNewPage{ + Page: makePage(r, &Page{Title: "New State", Section: "states"}), + }) + }) +} + +func handleStatesNewPOST(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // file upload limit of 20MB + if err := r.ParseMultipartForm(20 << 20); err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + file, _, err := r.FormFile("file") + if err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + defer file.Close() + statePath := r.FormValue("path") + parsedStatePath, err := url.Parse(statePath) + if err != nil || path.Clean(parsedStatePath.Path) != statePath || statePath[0] != '/' { + render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{ + Page: makePage(r, &Page{Title: "New State", Section: "states"}), + Path: statePath, + PathError: true, + }) + return + } + data, err := io.ReadAll(file) + if err != nil { + errorResponse(w, http.StatusBadRequest, fmt.Errorf("failed to read uploaded file: %w", err)) + return + } + fileType := http.DetectContentType(data) + if fileType != "text/plain; charset=utf-8" { + errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid file type: expected \"text/plain; charset=utf-8\" but got \"%s\"", fileType)) + return + } + account := r.Context().Value(model.AccountContextKey{}).(*model.Account) + version, err := db.CreateState(statePath, account.Id, data) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if version == nil { + render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{ + Page: makePage(r, &Page{Title: "New State", Section: "states"}), + Path: statePath, + PathDuplicate: true, + }) + return + } + destination := path.Join("/version", strconv.Itoa(version.Id)) + http.Redirect(w, r, destination, http.StatusFound) + }) +} diff --git a/pkg/webui/static/favicon.svg b/pkg/webui/static/favicon.svg new file mode 100644 index 0000000..56f9365 --- /dev/null +++ b/pkg/webui/static/favicon.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(0, 0, 0);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="98px" height="94px" viewBox="-0.5 -0.5 98 94"><defs/><rect fill="#000000" width="100%" height="100%" x="0" y="0"/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="d8vDQkJiZMsKFp_Zf0So-1"><g><rect x="0" y="0" width="98" height="94" fill="none" stroke="none" pointer-events="all"/><path d="M 89.23 80.02 L 89.23 31.87 L 76.66 31.87 L 76.66 80.02 L 66.61 80.02 L 66.61 31.87 L 54.04 31.87 L 54.04 80.02 L 43.99 80.02 L 43.99 31.87 L 31.42 31.87 L 31.42 80.02 L 21.36 80.02 L 21.36 31.87 L 8.8 31.87 L 8.8 80.02 L 8.79 80.02 L 0 94 L 98 93.98 Z M 2.49 26.57 L 95.51 26.57 L 49 0 Z" fill="#e6e6e6" stroke="none" pointer-events="all"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/pkg/webui/static/main.css b/pkg/webui/static/main.css new file mode 100644 index 0000000..ae56cd0 --- /dev/null +++ b/pkg/webui/static/main.css @@ -0,0 +1,8 @@ +table tbody a { + display: block; + text-decoration: none; + transition: all 0.25s ease-out; +} +.clickable-rows tbody tr:hover a { + background-color: var(--secondary-container); +} diff --git a/pkg/webui/version.go b/pkg/webui/version.go new file mode 100644 index 0000000..a577d5f --- /dev/null +++ b/pkg/webui/version.go @@ -0,0 +1,62 @@ +package webui + +import ( + "fmt" + "html/template" + "net/http" + "strconv" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var versionTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/version.html")) + +func handleVersionGET(db *database.DB) http.Handler { + type VersionsData struct { + Page *Page + Account *model.Account + State *model.State + Version *model.Version + VersionData string + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionIdStr := r.PathValue("id") + versionId, err := strconv.Atoi(versionIdStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + version, err := db.LoadVersionById(versionId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if version == nil { + errorResponse(w, http.StatusNotFound, err) + return + } + state, err := db.LoadStateById(version.StateId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + account, err := db.LoadAccountById(version.AccountId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + versionData := string(version.Data[:]) + render(w, versionTemplate, http.StatusOK, VersionsData{ + Page: makePage(r, &Page{ + Precedent: fmt.Sprintf("/state/%d", state.Id), + Section: "states", + Title: state.Path, + }), + Account: account, + State: state, + Version: version, + VersionData: versionData, + }) + }) +} |