Compare commits
10 commits
98c7d6f578
...
6763596582
Author | SHA1 | Date | |
---|---|---|---|
6763596582 | |||
8322dd5a3f | |||
5c6ff8f901 | |||
a83296b79a | |||
6fd1663d8c | |||
169e2d0d9d | |||
ee4df34e1a | |||
517c134ccd | |||
fcc2206124 | |||
ab548d249b |
26 changed files with 485 additions and 163 deletions
4
go.mod
4
go.mod
|
@ -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
4
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.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=
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
type AccountContextKey struct{}
|
||||
|
||||
type Account struct {
|
||||
Id int
|
||||
Id string
|
||||
Username string
|
||||
Salt []byte
|
||||
PasswordHash []byte
|
||||
|
|
|
@ -8,7 +8,7 @@ type SessionContextKey struct{}
|
|||
|
||||
type Session struct {
|
||||
Id string
|
||||
AccountId int
|
||||
AccountId string
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
Data any
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
31
pkg/webui/accounts.go
Normal 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
28
pkg/webui/admin.go
Normal 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)
|
||||
}))
|
||||
}
|
||||
}
|
35
pkg/webui/html/accounts.html
Normal file
35
pkg/webui/html/accounts.html
Normal 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 }}
|
|
@ -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>
|
||||
|
|
|
@ -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 }}"
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
|
@ -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 }}
|
23
pkg/webui/html/versions.html
Normal file
23
pkg/webui/html/versions.html
Normal 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 }}
|
|
@ -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
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
Loading…
Add table
Reference in a new issue