summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore2
-rw-r--r--cmd/tfstated/main.go89
-rw-r--r--cmd/tfstated/main_test.go51
-rw-r--r--go.mod9
-rw-r--r--go.sum4
-rw-r--r--pkg/backend/delete.go (renamed from cmd/tfstated/delete.go)2
-rw-r--r--pkg/backend/get.go (renamed from cmd/tfstated/get.go)2
-rw-r--r--pkg/backend/healthz.go12
-rw-r--r--pkg/backend/lock.go (renamed from cmd/tfstated/lock.go)2
-rw-r--r--pkg/backend/post.go (renamed from cmd/tfstated/post.go)2
-rw-r--r--pkg/backend/routes.go (renamed from cmd/tfstated/routes.go)4
-rw-r--r--pkg/backend/run.go45
-rw-r--r--pkg/backend/unlock.go (renamed from cmd/tfstated/unlock.go)2
-rw-r--r--pkg/database/accounts.go147
-rw-r--r--pkg/database/db.go83
-rw-r--r--pkg/database/locks.go61
-rw-r--r--pkg/database/migrations.go44
-rw-r--r--pkg/database/sessions.go77
-rw-r--r--pkg/database/sql/000_init.sql19
-rw-r--r--pkg/database/states.go191
-rw-r--r--pkg/database/versions.go67
-rw-r--r--pkg/middlewares/basic_auth/middleware.go (renamed from pkg/basic_auth/middleware.go)5
-rw-r--r--pkg/middlewares/logger/body_writer.go (renamed from pkg/logger/body_writer.go)0
-rw-r--r--pkg/middlewares/logger/middleware.go (renamed from pkg/logger/middleware.go)0
-rw-r--r--pkg/model/account.go5
-rw-r--r--pkg/model/session.go20
-rw-r--r--pkg/model/settings.go7
-rw-r--r--pkg/model/state.go13
-rw-r--r--pkg/model/version.go15
-rw-r--r--pkg/webui/cache.go10
-rw-r--r--pkg/webui/error.go23
-rw-r--r--pkg/webui/healthz.go (renamed from cmd/tfstated/healthz.go)2
-rw-r--r--pkg/webui/html/base.html58
-rw-r--r--pkg/webui/html/error.html6
-rw-r--r--pkg/webui/html/login.html27
-rw-r--r--pkg/webui/html/logout.html5
-rw-r--r--pkg/webui/html/settings.html25
-rw-r--r--pkg/webui/html/state.html30
-rw-r--r--pkg/webui/html/states.html38
-rw-r--r--pkg/webui/html/states_new.html33
-rw-r--r--pkg/webui/html/version.html10
-rw-r--r--pkg/webui/index.go31
-rw-r--r--pkg/webui/login.go126
-rw-r--r--pkg/webui/logout.go29
-rw-r--r--pkg/webui/render.go22
-rw-r--r--pkg/webui/routes.go28
-rw-r--r--pkg/webui/run.go52
-rw-r--r--pkg/webui/sessions.go61
-rw-r--r--pkg/webui/settings.go53
-rw-r--r--pkg/webui/state.go54
-rw-r--r--pkg/webui/states.go29
-rw-r--r--pkg/webui/states_new.go84
-rw-r--r--pkg/webui/static/favicon.svg3
-rw-r--r--pkg/webui/static/main.css8
-rw-r--r--pkg/webui/version.go62
55 files changed, 1600 insertions, 289 deletions
diff --git a/.gitignore b/.gitignore
index 122fc54..d824783 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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():
diff --git a/go.mod b/go.mod
index 2733025..68dd8cd 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 2044398..4b3de8a 100644
--- a/go.sum
+++ b/go.sum
@@ -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,
+ })
+ })
+}