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
go 1.23.5
go 1.24.1
require (
github.com/mattn/go-sqlite3 v1.14.24
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=
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
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)
}
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)
@ -32,13 +36,14 @@ func (db *DB) InitAdminAccount() error {
salt := helpers.GenerateSalt()
hash := helpers.HashPassword(password.String(), salt)
if _, err := tx.ExecContext(db.ctx,
`INSERT INTO accounts(username, salt, password_hash, is_admin, settings)
VALUES ("admin", :salt, :hash, TRUE, :settings)
`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("salt", salt),
sql.Named("id", accountId),
sql.Named("hash", hash),
[]byte("{}"),
sql.Named("salt", salt),
sql.Named("settings", []byte("{}")),
); err == nil {
AdvertiseAdminPassword(password.String())
} 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(
`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[int]string)
accounts := make(map[string]string)
for rows.Next() {
var (
id int
id string
username string
)
err = rows.Scan(&id, &username)
@ -74,7 +115,7 @@ func (db *DB) LoadAccountUsernames() (map[int]string, error) {
return accounts, nil
}
func (db *DB) LoadAccountById(id int) (*model.Account, error) {
func (db *DB) LoadAccountById(id string) (*model.Account, error) {
account := model.Account{
Id: id,
}
@ -99,7 +140,7 @@ func (db *DB) LoadAccountById(id int) (*model.Account, error) {
if errors.Is(err, sql.ErrNoRows) {
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.LastLogin = time.Unix(lastLogin, 0)

View file

@ -104,7 +104,10 @@ func (db *DB) Close() error {
_ = db.writeDB.Close()
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) {

View file

@ -4,6 +4,9 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"go.n16f.net/uuid"
)
// 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 {
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
return err
} else {

View file

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

View file

@ -8,8 +8,54 @@ import (
"time"
"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
func (db *DB) DeleteState(path string) (bool, error) {
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)
}
func (db *DB) LoadStateById(stateId int) (*model.State, error) {
func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) {
state := model.State{
Id: stateId,
}
@ -76,8 +122,8 @@ func (db *DB) LoadStates() ([]model.State, error) {
defer rows.Close()
states := make([]model.State, 0)
for rows.Next() {
var state model.State
var (
state model.State
created int64
updated int64
)
@ -95,8 +141,8 @@ func (db *DB) LoadStates() ([]model.State, error) {
return states, nil
}
// returns true in case of id mismatch
func (db *DB) SetState(path string, accountID int, data []byte, lockID string) (bool, error) {
// returns true in case of lock mismatch
func (db *DB) SetState(path string, accountId string, data []byte, lock string) (bool, error) {
encryptedData, err := db.dataEncryptionKey.EncryptAES256(data)
if err != nil {
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
return ret, db.WithTransaction(func(tx *sql.Tx) error {
var (
stateID int64
stateId string
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) {
var result sql.Result
result, err = tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path)
var stateUUID uuid.UUID
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 {
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)
}
stateId = stateUUID.String()
} else {
return err
}
}
if lockID != "" && slices.Compare([]byte(lockID), lockData) != 0 {
err = fmt.Errorf("failed to update state, lock ID does not match")
if lock != "" && slices.Compare([]byte(lock), lockData) != 0 {
err = fmt.Errorf("failed to update state: lock ID mismatch")
ret = true
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,
`INSERT INTO versions(account_id, state_id, data, lock)
SELECT :accountID, :stateID, :data, lock
`INSERT INTO versions(id, account_id, state_id, data, lock)
SELECT :versionId, :accountId, :stateId, :data, lock
FROM states
WHERE states.id = :stateID;`,
sql.Named("accountID", accountID),
sql.Named("stateID", stateID),
sql.Named("data", encryptedData))
WHERE states.id = :stateId;`,
sql.Named("accountId", accountId),
sql.Named("data", encryptedData),
sql.Named("stateId", stateId),
sql.Named("versionId", versionId))
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)
stateId)
if err != nil {
return fmt.Errorf("failed to touch updated for state: %w", err)
}

View file

@ -7,9 +7,10 @@ import (
"time"
"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{
Id: id,
}
@ -29,12 +30,12 @@ func (db *DB) LoadVersionById(id int) (*model.Version, error) {
if errors.Is(err, sql.ErrNoRows) {
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.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData)
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
}

View file

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

View file

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

View file

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

View file

@ -1,14 +1,17 @@
package model
import (
"encoding/json"
"time"
"go.n16f.net/uuid"
)
type Version struct {
AccountId int
AccountId string
Created time.Time
Data []byte
Id int
Data json.RawMessage
Id uuid.UUID
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>
<span>Settings</span>
</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">
<i>logout</i>
<span>Logout</span>

View file

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

View file

@ -1,32 +1,71 @@
{{ define "main" }}
<main class="responsive" id="main">
<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>
<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">
<thead>
<tr>
<th>Path</th>
<th>Updated</th>
<th>Locked</th>
</tr>
</thead>
<tbody>
{{ range .States }}
<tr>
<td><a href="/states/{{ .Id }}">{{ .Path }}</a></td>
<td><a href="/states/{{ .Id }}">{{ .Updated }}</a></td>
<td>
<a href="/states/{{ .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>
</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 }}
</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</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>
{{ end }}

View file

@ -20,8 +20,8 @@
<tbody>
{{ range .Versions }}
<tr>
<td><a href="/version/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td>
<td><a href="/version/{{ .Id }}">{{ .Created }}</a></td>
<td><a href="/versions/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td>
<td><a href="/versions/{{ .Id }}">{{ .Created }}</a></td>
</tr>
{{ end }}
</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 {
IsAdmin bool
LightMode bool
Precedent string
Section string
@ -15,6 +16,8 @@ type Page struct {
}
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)
page.LightMode = settings.LightMode
return page

View file

@ -12,6 +12,8 @@ func addRoutes(
) {
requireSession := sessionsMiddleware(db)
requireLogin := loginMiddleware(db, requireSession)
requireAdmin := adminMiddleware(db, requireLogin)
mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db)))
mux.Handle("GET /healthz", handleHealthz())
mux.Handle("GET /login", requireSession(handleLoginGET()))
mux.Handle("POST /login", requireSession(handleLoginPOST(db)))
@ -19,8 +21,9 @@ func addRoutes(
mux.Handle("GET /settings", requireLogin(handleSettingsGET(db)))
mux.Handle("POST /settings", requireLogin(handleSettingsPOST(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 /version/{id}", requireLogin(handleVersionGET(db)))
mux.Handle("GET /versions/{id}", requireLogin(handleVersionsGET(db)))
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
import (
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"path"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
)
type StatesPage struct {
ActiveTab int
Page *Page
Path string
PathError bool
PathDuplicate bool
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 {
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{
render(w, statesTemplates, http.StatusOK, StatesPage{
Page: makePage(r, &Page{Title: "States", Section: "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
import (
"fmt"
"html/template"
"net/http"
"strconv"
"path"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"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 {
Page *Page
Account *model.Account
@ -21,9 +21,8 @@ func handleVersionGET(db *database.DB) http.Handler {
VersionData string
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
versionIdStr := r.PathValue("id")
versionId, err := strconv.Atoi(versionIdStr)
if err != nil {
var versionId uuid.UUID
if err := versionId.Parse(r.PathValue("id")); err != nil {
errorResponse(w, http.StatusBadRequest, err)
return
}
@ -32,6 +31,10 @@ func handleVersionGET(db *database.DB) http.Handler {
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)
@ -43,9 +46,9 @@ func handleVersionGET(db *database.DB) http.Handler {
return
}
versionData := string(version.Data[:])
render(w, versionTemplate, http.StatusOK, VersionsData{
render(w, versionsTemplate, http.StatusOK, VersionsData{
Page: makePage(r, &Page{
Precedent: fmt.Sprintf("/state/%d", state.Id),
Precedent: path.Join("/states/", state.Id.String()),
Section: "states",
Title: state.Path,
}),