Compare commits

...

10 commits

26 changed files with 485 additions and 163 deletions

4
go.mod
View file

@ -1,9 +1,9 @@
module git.adyxax.org/adyxax/tfstated module git.adyxax.org/adyxax/tfstated
go 1.23.5 go 1.24.1
require ( require (
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.36.0
) )

4
go.sum
View file

@ -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= 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 h1:QYbHaaFqx6hMor1L6iMSmyhMFvXQXhKaNk9nefug07M=
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU= go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=

View file

@ -25,6 +25,10 @@ func (db *DB) InitAdminAccount() error {
return fmt.Errorf("failed to select if there is an admin account in the database: %w", err) return fmt.Errorf("failed to select if there is an admin account in the database: %w", err)
} }
if !hasAdminAccount { 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 var password uuid.UUID
if err := password.Generate(uuid.V4); err != nil { if err := password.Generate(uuid.V4); err != nil {
return fmt.Errorf("failed to generate initial admin password: %w", err) return fmt.Errorf("failed to generate initial admin password: %w", err)
@ -32,13 +36,14 @@ func (db *DB) InitAdminAccount() error {
salt := helpers.GenerateSalt() salt := helpers.GenerateSalt()
hash := helpers.HashPassword(password.String(), salt) hash := helpers.HashPassword(password.String(), salt)
if _, err := tx.ExecContext(db.ctx, if _, err := tx.ExecContext(db.ctx,
`INSERT INTO accounts(username, salt, password_hash, is_admin, settings) `INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings)
VALUES ("admin", :salt, :hash, TRUE, :settings) VALUES (:id, "admin", :salt, :hash, TRUE, :settings)
ON CONFLICT DO UPDATE SET password_hash = :hash ON CONFLICT DO UPDATE SET password_hash = :hash
WHERE username = "admin";`, WHERE username = "admin";`,
sql.Named("salt", salt), sql.Named("id", accountId),
sql.Named("hash", hash), sql.Named("hash", hash),
[]byte("{}"), sql.Named("salt", salt),
sql.Named("settings", []byte("{}")),
); err == nil { ); err == nil {
AdvertiseAdminPassword(password.String()) AdvertiseAdminPassword(password.String())
} else { } else {
@ -49,17 +54,53 @@ func (db *DB) InitAdminAccount() error {
}) })
} }
func (db *DB) LoadAccountUsernames() (map[int]string, error) { func (db *DB) LoadAccounts() ([]model.Account, error) {
rows, err := db.Query(
`SELECT id, username, salt, password_hash, is_admin, created, last_login, settings FROM accounts;`)
if err != nil {
return nil, fmt.Errorf("failed to load accounts from database: %w", err)
}
defer rows.Close()
accounts := make([]model.Account, 0)
for rows.Next() {
var (
account model.Account
created int64
lastLogin int64
)
err = rows.Scan(
&account.Id,
&account.Username,
&account.Salt,
&account.PasswordHash,
&account.IsAdmin,
&created,
&lastLogin,
&account.Settings)
if err != nil {
return nil, fmt.Errorf("failed to load account from row: %w", err)
}
account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0)
accounts = append(accounts, account)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to load accounts from rows: %w", err)
}
return accounts, nil
}
func (db *DB) LoadAccountUsernames() (map[string]string, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT id, username FROM accounts;`) `SELECT id, username FROM accounts;`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load accounts from database: %w", err) return nil, fmt.Errorf("failed to load accounts from database: %w", err)
} }
defer rows.Close() defer rows.Close()
accounts := make(map[int]string) accounts := make(map[string]string)
for rows.Next() { for rows.Next() {
var ( var (
id int id string
username string username string
) )
err = rows.Scan(&id, &username) err = rows.Scan(&id, &username)
@ -74,7 +115,7 @@ func (db *DB) LoadAccountUsernames() (map[int]string, error) {
return accounts, nil return accounts, nil
} }
func (db *DB) LoadAccountById(id int) (*model.Account, error) { func (db *DB) LoadAccountById(id string) (*model.Account, error) {
account := model.Account{ account := model.Account{
Id: id, Id: id,
} }
@ -99,7 +140,7 @@ func (db *DB) LoadAccountById(id int) (*model.Account, error) {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to load account by id %d: %w", id, err) return nil, fmt.Errorf("failed to load account by id %s: %w", id, err)
} }
account.Created = time.Unix(created, 0) account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)

View file

@ -104,7 +104,10 @@ func (db *DB) Close() error {
_ = db.writeDB.Close() _ = db.writeDB.Close()
return fmt.Errorf("failed to close read database connection: %w", err) return fmt.Errorf("failed to close read database connection: %w", err)
} }
return db.writeDB.Close() if err := db.writeDB.Close(); err != nil {
return fmt.Errorf("failed to close write database connection: %w", err)
}
return nil
} }
func (db *DB) Exec(query string, args ...any) (sql.Result, error) { func (db *DB) Exec(query string, args ...any) (sql.Result, error) {

View file

@ -4,6 +4,9 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"go.n16f.net/uuid"
) )
// Atomically check the lock status of a state and lock it if unlocked. Returns // Atomically check the lock status of a state and lock it if unlocked. Returns
@ -18,7 +21,11 @@ func (db *DB) SetLockOrGetExistingLock(path string, lock any) (bool, error) {
if lockData, err = json.Marshal(lock); err != nil { if lockData, err = json.Marshal(lock); err != nil {
return err return err
} }
_, err = tx.ExecContext(db.ctx, `INSERT INTO states(path, lock) VALUES (?, json(?))`, path, lockData) var stateId uuid.UUID
if err := stateId.Generate(uuid.V7); err != nil {
return fmt.Errorf("failed to generate state id: %w", err)
}
_, err = tx.ExecContext(db.ctx, `INSERT INTO states(id, path, lock) VALUES (?, ?, json(?))`, stateId, path, lockData)
ret = true ret = true
return err return err
} else { } else {

View file

@ -3,7 +3,7 @@ CREATE TABLE schema_version (
) STRICT; ) STRICT;
CREATE TABLE accounts ( CREATE TABLE accounts (
id INTEGER PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
salt BLOB NOT NULL, salt BLOB NOT NULL,
password_hash BLOB NOT NULL, password_hash BLOB NOT NULL,
@ -16,7 +16,7 @@ CREATE UNIQUE INDEX accounts_username on accounts(username);
CREATE TABLE sessions ( CREATE TABLE sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL, account_id TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()), created INTEGER NOT NULL DEFAULT (unixepoch()),
updated INTEGER NOT NULL DEFAULT (unixepoch()), updated INTEGER NOT NULL DEFAULT (unixepoch()),
data TEXT NOT NULL, data TEXT NOT NULL,
@ -24,7 +24,7 @@ CREATE TABLE sessions (
) STRICT; ) STRICT;
CREATE TABLE states ( CREATE TABLE states (
id INTEGER PRIMARY KEY, id TEXT PRIMARY KEY,
path TEXT NOT NULL, path TEXT NOT NULL,
lock TEXT, lock TEXT,
created INTEGER DEFAULT (unixepoch()), created INTEGER DEFAULT (unixepoch()),
@ -33,9 +33,9 @@ CREATE TABLE states (
CREATE UNIQUE INDEX states_path on states(path); CREATE UNIQUE INDEX states_path on states(path);
CREATE TABLE versions ( CREATE TABLE versions (
id INTEGER PRIMARY KEY, id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL, account_id TEXT NOT NULL,
state_id INTEGER, state_id TEXT,
data BLOB, data BLOB,
lock TEXT, lock TEXT,
created INTEGER DEFAULT (unixepoch()), created INTEGER DEFAULT (unixepoch()),

View file

@ -8,8 +8,54 @@ import (
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"github.com/mattn/go-sqlite3"
"go.n16f.net/uuid"
) )
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)
}
var stateId uuid.UUID
if err := stateId.Generate(uuid.V7); err != nil {
return nil, fmt.Errorf("failed to generate state id: %w", err)
}
var versionId uuid.UUID
if err := versionId.Generate(uuid.V7); err != nil {
return nil, fmt.Errorf("failed to generate version id: %w", err)
}
version := &model.Version{
AccountId: accountId,
Id: versionId,
StateId: stateId,
}
return version, db.WithTransaction(func(tx *sql.Tx) error {
_, err := tx.ExecContext(db.ctx, `INSERT INTO states(id, path) VALUES (?, ?)`, stateId, 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)
}
_, err = tx.ExecContext(db.ctx,
`INSERT INTO versions(id, account_id, data, state_id)
VALUES (:id, :accountID, :data, :stateID)`,
sql.Named("accountID", accountId),
sql.Named("data", encryptedData),
sql.Named("id", versionId),
sql.Named("stateID", stateId))
if err != nil {
return fmt.Errorf("failed to insert new state version: %w", err)
}
return nil
})
}
// returns true in case of successful deletion // returns true in case of successful deletion
func (db *DB) DeleteState(path string) (bool, error) { func (db *DB) DeleteState(path string) (bool, error) {
result, err := db.Exec(`DELETE FROM states WHERE path = ?;`, path) result, err := db.Exec(`DELETE FROM states WHERE path = ?;`, path)
@ -45,7 +91,7 @@ func (db *DB) GetState(path string) ([]byte, error) {
return db.dataEncryptionKey.DecryptAES256(encryptedData) return db.dataEncryptionKey.DecryptAES256(encryptedData)
} }
func (db *DB) LoadStateById(stateId int) (*model.State, error) { func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) {
state := model.State{ state := model.State{
Id: stateId, Id: stateId,
} }
@ -76,8 +122,8 @@ func (db *DB) LoadStates() ([]model.State, error) {
defer rows.Close() defer rows.Close()
states := make([]model.State, 0) states := make([]model.State, 0)
for rows.Next() { for rows.Next() {
var state model.State
var ( var (
state model.State
created int64 created int64
updated int64 updated int64
) )
@ -95,8 +141,8 @@ func (db *DB) LoadStates() ([]model.State, error) {
return states, nil return states, nil
} }
// returns true in case of id mismatch // returns true in case of lock mismatch
func (db *DB) SetState(path string, accountID int, data []byte, lockID string) (bool, error) { func (db *DB) SetState(path string, accountId string, data []byte, lock string) (bool, error) {
encryptedData, err := db.dataEncryptionKey.EncryptAES256(data) encryptedData, err := db.dataEncryptionKey.EncryptAES256(data)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to encrypt state data: %w", err) return false, fmt.Errorf("failed to encrypt state data: %w", err)
@ -104,45 +150,50 @@ func (db *DB) SetState(path string, accountID int, data []byte, lockID string) (
ret := false ret := false
return ret, db.WithTransaction(func(tx *sql.Tx) error { return ret, db.WithTransaction(func(tx *sql.Tx) error {
var ( var (
stateID int64 stateId string
lockData []byte lockData []byte
) )
if err = tx.QueryRowContext(db.ctx, `SELECT id, lock->>'ID' FROM states WHERE path = ?;`, path).Scan(&stateID, &lockData); err != nil { 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) { if errors.Is(err, sql.ErrNoRows) {
var result sql.Result var stateUUID uuid.UUID
result, err = tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path) if err := stateUUID.Generate(uuid.V7); err != nil {
return fmt.Errorf("failed to generate state id: %w", err)
}
_, err = tx.ExecContext(db.ctx, `INSERT INTO states(id, path) VALUES (?, ?)`, stateUUID, path)
if err != nil { if err != nil {
return fmt.Errorf("failed to insert new state: %w", err) return fmt.Errorf("failed to insert new state: %w", err)
} }
stateID, err = result.LastInsertId() stateId = stateUUID.String()
if err != nil {
return fmt.Errorf("failed to get last insert id for new state: %w", err)
}
} else { } else {
return err return err
} }
} }
if lockID != "" && slices.Compare([]byte(lockID), lockData) != 0 { if lock != "" && slices.Compare([]byte(lock), lockData) != 0 {
err = fmt.Errorf("failed to update state, lock ID does not match") err = fmt.Errorf("failed to update state: lock ID mismatch")
ret = true ret = true
return err return err
} }
var versionId uuid.UUID
if err := versionId.Generate(uuid.V7); err != nil {
return fmt.Errorf("failed to generate version id: %w", err)
}
_, err = tx.ExecContext(db.ctx, _, err = tx.ExecContext(db.ctx,
`INSERT INTO versions(account_id, state_id, data, lock) `INSERT INTO versions(id, account_id, state_id, data, lock)
SELECT :accountID, :stateID, :data, lock SELECT :versionId, :accountId, :stateId, :data, lock
FROM states FROM states
WHERE states.id = :stateID;`, WHERE states.id = :stateId;`,
sql.Named("accountID", accountID), sql.Named("accountId", accountId),
sql.Named("stateID", stateID), sql.Named("data", encryptedData),
sql.Named("data", encryptedData)) sql.Named("stateId", stateId),
sql.Named("versionId", versionId))
if err != nil { if err != nil {
return fmt.Errorf("failed to insert new state version: %w", err) return fmt.Errorf("failed to insert new state version: %w", err)
} }
_, err = tx.ExecContext(db.ctx, _, err = tx.ExecContext(db.ctx,
`UPDATE states SET updated = ? WHERE id = ?;`, `UPDATE states SET updated = ? WHERE id = ?;`,
time.Now().UTC().Unix(), time.Now().UTC().Unix(),
stateID) stateId)
if err != nil { if err != nil {
return fmt.Errorf("failed to touch updated for state: %w", err) return fmt.Errorf("failed to touch updated for state: %w", err)
} }

View file

@ -7,9 +7,10 @@ import (
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
) )
func (db *DB) LoadVersionById(id int) (*model.Version, error) { func (db *DB) LoadVersionById(id uuid.UUID) (*model.Version, error) {
version := model.Version{ version := model.Version{
Id: id, Id: id,
} }
@ -29,12 +30,12 @@ func (db *DB) LoadVersionById(id int) (*model.Version, error) {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to load version id %d from database: %w", id, err) return nil, fmt.Errorf("failed to load version id %s from database: %w", id, err)
} }
version.Created = time.Unix(created, 0) version.Created = time.Unix(created, 0)
version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData) version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt version %d data: %w", id, err) return nil, fmt.Errorf("failed to decrypt version %s data: %w", id, err)
} }
return &version, nil return &version, nil
} }

View file

@ -11,7 +11,7 @@ import (
type AccountContextKey struct{} type AccountContextKey struct{}
type Account struct { type Account struct {
Id int Id string
Username string Username string
Salt []byte Salt []byte
PasswordHash []byte PasswordHash []byte

View file

@ -8,7 +8,7 @@ type SessionContextKey struct{}
type Session struct { type Session struct {
Id string Id string
AccountId int AccountId string
Created time.Time Created time.Time
Updated time.Time Updated time.Time
Data any Data any

View file

@ -2,11 +2,13 @@ package model
import ( import (
"time" "time"
"go.n16f.net/uuid"
) )
type State struct { type State struct {
Created time.Time Created time.Time
Id int Id uuid.UUID
Lock *string Lock *string
Path string Path string
Updated time.Time Updated time.Time

View file

@ -1,14 +1,17 @@
package model package model
import ( import (
"encoding/json"
"time" "time"
"go.n16f.net/uuid"
) )
type Version struct { type Version struct {
AccountId int AccountId string
Created time.Time Created time.Time
Data []byte Data json.RawMessage
Id int Id uuid.UUID
Lock *string Lock *string
StateId int StateId uuid.UUID
} }

31
pkg/webui/accounts.go Normal file
View file

@ -0,0 +1,31 @@
package webui
import (
"html/template"
"net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
)
type AccountsPage struct {
ActiveTab int
Page *Page
Accounts []model.Account
}
var accountsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accounts.html"))
func handleAccountsGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accounts, err := db.LoadAccounts()
if err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
render(w, accountsTemplates, http.StatusOK, AccountsPage{
Page: makePage(r, &Page{Title: "User Accounts", Section: "accounts"}),
Accounts: accounts,
})
})
}

28
pkg/webui/admin.go Normal file
View file

@ -0,0 +1,28 @@
package webui
import (
"fmt"
"net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
)
func adminMiddleware(db *database.DB, requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := r.Context().Value(model.AccountContextKey{})
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
}
if !account.(*model.Account).IsAdmin {
errorResponse(w, http.StatusForbidden, fmt.Errorf("Only administrators can perform this request."))
return
}
next.ServeHTTP(w, r)
}))
}
}

View file

@ -0,0 +1,35 @@
{{ define "main" }}
<main class="responsive" id="main">
<div>
<div class="tabs">
<a data-ui="#explorer"{{ if eq .ActiveTab 0 }} class="active"{{ end }}>User Accounts</a>
<a data-ui="#new"{{ if eq .ActiveTab 1 }} class="active"{{ end }}>Create New User Account</a>
</div>
<div id="explorer" class="page padding{{ if eq .ActiveTab 0 }} active{{ end }}">
<table class="clickable-rows no-space">
<thead>
<tr>
<th>Username</th>
<th>Created</th>
<th>Last Login</th>
<th>Is Admin</th>
</tr>
</thead>
<tbody>
{{ range .Accounts }}
<tr>
<td><a href="/accounts/{{ .Id }}">{{ .Username }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .Created }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .LastLogin }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .IsAdmin }}</a></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<div id="new" class="page padding{{ if eq .ActiveTab 1 }} active{{ end }}">
<p>TODO</p>
</div>
</div>
</main>
{{ end }}

View file

@ -20,6 +20,12 @@
<i>settings</i> <i>settings</i>
<span>Settings</span> <span>Settings</span>
</a> </a>
{{ if .Page.IsAdmin }}
<a href="/accounts"{{ if eq .Page.Section "accounts" }} class="fill"{{ end}}>
<i>person</i>
<span>User Accounts</span>
</a>
{{ end }}
<a href="/logout"> <a href="/logout">
<i>logout</i> <i>logout</i>
<span>Logout</span> <span>Logout</span>

View file

@ -3,7 +3,8 @@
<form action="/login" method="post"> <form action="/login" method="post">
<fieldset> <fieldset>
<div class="field border label{{ if .Forbidden }} invalid{{ end }}"> <div class="field border label{{ if .Forbidden }} invalid{{ end }}">
<input id="username" <input autofocus
id="username"
name="username" name="username"
type="text" type="text"
value="{{ .Username }}" value="{{ .Username }}"

View file

@ -1,5 +1,11 @@
{{ define "main" }} {{ define "main" }}
<main class="responsive" id="main"> <main class="responsive" id="main">
<div>
<div class="tabs">
<a data-ui="#explorer"{{ if eq .ActiveTab 0 }} class="active"{{ end }}>States</a>
<a data-ui="#new"{{ if eq .ActiveTab 1 }} class="active"{{ end }}>Create New State</a>
</div>
<div id="explorer" class="page padding{{ if eq .ActiveTab 0 }} active{{ end }}">
<table class="clickable-rows no-space"> <table class="clickable-rows no-space">
<thead> <thead>
<tr> <tr>
@ -11,10 +17,10 @@
<tbody> <tbody>
{{ range .States }} {{ range .States }}
<tr> <tr>
<td><a href="/state/{{ .Id }}">{{ .Path }}</a></td> <td><a href="/states/{{ .Id }}">{{ .Path }}</a></td>
<td><a href="/state/{{ .Id }}">{{ .Updated }}</a></td> <td><a href="/states/{{ .Id }}">{{ .Updated }}</a></td>
<td> <td>
<a href="/state/{{ .Id }}"> <a href="/states/{{ .Id }}">
{{ if eq .Lock nil }}no{{ else }} {{ if eq .Lock nil }}no{{ else }}
<span>yes</span> <span>yes</span>
<div class="tooltip left max"> <div class="tooltip left max">
@ -28,5 +34,38 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
</div>
<div id="new" class="page padding{{ if eq .ActiveTab 1 }} active{{ end }}">
<form action="/states" 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>
</div>
</div>
</main> </main>
{{ end }} {{ end }}

View file

@ -20,8 +20,8 @@
<tbody> <tbody>
{{ range .Versions }} {{ range .Versions }}
<tr> <tr>
<td><a href="/version/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td> <td><a href="/versions/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td>
<td><a href="/version/{{ .Id }}">{{ .Created }}</a></td> <td><a href="/versions/{{ .Id }}">{{ .Created }}</a></td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>

View file

@ -1,10 +0,0 @@
{{ 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 }}

View file

@ -0,0 +1,23 @@
{{ define "main" }}
<main class="responsive" id="main">
<p>
Created by
<a href="/accounts/{{ .Account.Id }}" class="link underline">{{ .Account.Username }}</a>
at {{ .Version.Created }}
</p>
<div>
<div class="tabs">
<a data-ui="#explorer" class="active">Explorer</a>
<a data-ui="#raw">Raw</a>
</div>
<div id="explorer" class="page padding active">
<article class="border medium no-padding center-align middle-align">
<progress class="circle large"></progress>
</article>
</div>
<div id="raw" class="page padding">
<pre><code id="raw-state">{{ .VersionData }}</code></pre>
</div>
</div>
</main>
{{ end }}

View file

@ -8,6 +8,7 @@ import (
) )
type Page struct { type Page struct {
IsAdmin bool
LightMode bool LightMode bool
Precedent string Precedent string
Section string Section string
@ -15,6 +16,8 @@ type Page struct {
} }
func makePage(r *http.Request, page *Page) *Page { func makePage(r *http.Request, page *Page) *Page {
account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
page.IsAdmin = account.IsAdmin
settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings) settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
page.LightMode = settings.LightMode page.LightMode = settings.LightMode
return page return page

View file

@ -12,6 +12,8 @@ func addRoutes(
) { ) {
requireSession := sessionsMiddleware(db) requireSession := sessionsMiddleware(db)
requireLogin := loginMiddleware(db, requireSession) requireLogin := loginMiddleware(db, requireSession)
requireAdmin := adminMiddleware(db, requireLogin)
mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db)))
mux.Handle("GET /healthz", handleHealthz()) mux.Handle("GET /healthz", handleHealthz())
mux.Handle("GET /login", requireSession(handleLoginGET())) mux.Handle("GET /login", requireSession(handleLoginGET()))
mux.Handle("POST /login", requireSession(handleLoginPOST(db))) mux.Handle("POST /login", requireSession(handleLoginPOST(db)))
@ -19,8 +21,9 @@ func addRoutes(
mux.Handle("GET /settings", requireLogin(handleSettingsGET(db))) mux.Handle("GET /settings", requireLogin(handleSettingsGET(db)))
mux.Handle("POST /settings", requireLogin(handleSettingsPOST(db))) mux.Handle("POST /settings", requireLogin(handleSettingsPOST(db)))
mux.Handle("GET /states", requireLogin(handleStatesGET(db))) mux.Handle("GET /states", requireLogin(handleStatesGET(db)))
mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db))) mux.Handle("POST /states", requireLogin(handleStatesPOST(db)))
mux.Handle("GET /states/{id}", requireLogin(handleStatesIdGET(db)))
mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS)))) mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
mux.Handle("GET /version/{id}", requireLogin(handleVersionGET(db))) mux.Handle("GET /versions/{id}", requireLogin(handleVersionsGET(db)))
mux.Handle("GET /", requireLogin(handleIndexGET())) mux.Handle("GET /", requireLogin(handleIndexGET()))
} }

View file

@ -1,54 +0,0 @@
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[int]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,
})
})
}

View file

@ -1,29 +1,135 @@
package webui package webui
import ( import (
"fmt"
"html/template" "html/template"
"io"
"net/http" "net/http"
"net/url"
"path"
"git.adyxax.org/adyxax/tfstated/pkg/database" "git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
) )
var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states.html")) type StatesPage struct {
ActiveTab int
func handleStatesGET(db *database.DB) http.Handler {
type StatesData struct {
Page *Page Page *Page
Path string
PathError bool
PathDuplicate bool
States []model.State States []model.State
} }
var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states.html"))
var statesIdTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/statesId.html"))
func handleStatesGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
states, err := db.LoadStates() states, err := db.LoadStates()
if err != nil { if err != nil {
errorResponse(w, http.StatusInternalServerError, err) errorResponse(w, http.StatusInternalServerError, err)
return return
} }
render(w, statesTemplates, http.StatusOK, StatesData{ render(w, statesTemplates, http.StatusOK, StatesPage{
Page: makePage(r, &Page{Title: "States", Section: "states"}), Page: makePage(r, &Page{Title: "States", Section: "states"}),
States: states, States: states,
}) })
}) })
} }
func handleStatesPOST(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, statesTemplates, http.StatusBadRequest, StatesPage{
ActiveTab: 1,
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, statesTemplates, http.StatusBadRequest, StatesPage{
ActiveTab: 1,
Page: makePage(r, &Page{Title: "New State", Section: "states"}),
Path: statePath,
PathDuplicate: true,
})
return
}
destination := path.Join("/versions", version.Id.String())
http.Redirect(w, r, destination, http.StatusFound)
})
}
func handleStatesIdGET(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) {
var stateId uuid.UUID
if err := stateId.Parse(r.PathValue("id")); 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, statesIdTemplate, http.StatusOK, StatesData{
Page: makePage(r, &Page{
Precedent: "/states",
Section: "states",
Title: state.Path,
}),
State: state,
Usernames: usernames,
Versions: versions,
})
})
}

View file

@ -1,18 +1,18 @@
package webui package webui
import ( import (
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"strconv" "path"
"git.adyxax.org/adyxax/tfstated/pkg/database" "git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
) )
var versionTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/version.html")) var versionsTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/versions.html"))
func handleVersionGET(db *database.DB) http.Handler { func handleVersionsGET(db *database.DB) http.Handler {
type VersionsData struct { type VersionsData struct {
Page *Page Page *Page
Account *model.Account Account *model.Account
@ -21,9 +21,8 @@ func handleVersionGET(db *database.DB) http.Handler {
VersionData string VersionData string
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
versionIdStr := r.PathValue("id") var versionId uuid.UUID
versionId, err := strconv.Atoi(versionIdStr) if err := versionId.Parse(r.PathValue("id")); err != nil {
if err != nil {
errorResponse(w, http.StatusBadRequest, err) errorResponse(w, http.StatusBadRequest, err)
return return
} }
@ -32,6 +31,10 @@ func handleVersionGET(db *database.DB) http.Handler {
errorResponse(w, http.StatusInternalServerError, err) errorResponse(w, http.StatusInternalServerError, err)
return return
} }
if version == nil {
errorResponse(w, http.StatusNotFound, err)
return
}
state, err := db.LoadStateById(version.StateId) state, err := db.LoadStateById(version.StateId)
if err != nil { if err != nil {
errorResponse(w, http.StatusInternalServerError, err) errorResponse(w, http.StatusInternalServerError, err)
@ -43,9 +46,9 @@ func handleVersionGET(db *database.DB) http.Handler {
return return
} }
versionData := string(version.Data[:]) versionData := string(version.Data[:])
render(w, versionTemplate, http.StatusOK, VersionsData{ render(w, versionsTemplate, http.StatusOK, VersionsData{
Page: makePage(r, &Page{ Page: makePage(r, &Page{
Precedent: fmt.Sprintf("/state/%d", state.Id), Precedent: path.Join("/states/", state.Id.String()),
Section: "states", Section: "states",
Title: state.Path, Title: state.Path,
}), }),