summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulien Dessaux2025-02-04 23:34:26 +0100
committerJulien Dessaux2025-02-04 23:34:26 +0100
commitfcc220612495a21067f9043396ba63062537ad63 (patch)
tree6ffb59e3f61fdc433a2f7762465e43f6d326d31d
parentchore(tfstated): change database account id format to uuidv7 (diff)
downloadtfstated-fcc220612495a21067f9043396ba63062537ad63.tar.gz
tfstated-fcc220612495a21067f9043396ba63062537ad63.tar.bz2
tfstated-fcc220612495a21067f9043396ba63062537ad63.zip
feat(webui): add state creation page
Diffstat (limited to '')
-rw-r--r--pkg/database/states.go44
-rw-r--r--pkg/webui/html/states.html6
-rw-r--r--pkg/webui/html/states_new.html33
-rw-r--r--pkg/webui/routes.go2
-rw-r--r--pkg/webui/states_new.go84
5 files changed, 169 insertions, 0 deletions
diff --git a/pkg/database/states.go b/pkg/database/states.go
index ccae942..75af2e5 100644
--- a/pkg/database/states.go
+++ b/pkg/database/states.go
@@ -8,8 +8,52 @@ import (
"time"
"git.adyxax.org/adyxax/tfstated/pkg/model"
+ "github.com/mattn/go-sqlite3"
)
+func (db *DB) CreateState(path string, accountId string, data []byte) (*model.Version, error) {
+ encryptedData, err := db.dataEncryptionKey.EncryptAES256(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt state data: %w", err)
+ }
+ version := &model.Version{
+ AccountId: accountId,
+ }
+ return version, db.WithTransaction(func(tx *sql.Tx) error {
+ result, err := tx.ExecContext(db.ctx, `INSERT INTO states(path) VALUES (?)`, path)
+ if err != nil {
+ var sqliteErr sqlite3.Error
+ if errors.As(err, &sqliteErr) {
+ if sqliteErr.Code == sqlite3.ErrNo(sqlite3.ErrConstraint) {
+ version = nil
+ return nil
+ }
+ }
+ return fmt.Errorf("failed to insert new state: %w", err)
+ }
+ stateId, err := result.LastInsertId()
+ if err != nil {
+ return fmt.Errorf("failed to get last insert id for new state: %w", err)
+ }
+ version.StateId = int(stateId)
+ result, err = tx.ExecContext(db.ctx,
+ `INSERT INTO versions(account_id, data, state_id)
+ VALUES (:accountID, :data, :stateID)`,
+ sql.Named("accountID", accountId),
+ sql.Named("data", encryptedData),
+ sql.Named("stateID", stateId))
+ if err != nil {
+ return fmt.Errorf("failed to insert new state version: %w", err)
+ }
+ versionId, err := result.LastInsertId()
+ if err != nil {
+ return fmt.Errorf("failed to get last insert id for new version of the state: %w", err)
+ }
+ version.Id = int(versionId)
+ return nil
+ })
+}
+
// returns true in case of successful deletion
func (db *DB) DeleteState(path string) (bool, error) {
result, err := db.Exec(`DELETE FROM states WHERE path = ?;`, path)
diff --git a/pkg/webui/html/states.html b/pkg/webui/html/states.html
index 787ac73..37d80cf 100644
--- a/pkg/webui/html/states.html
+++ b/pkg/webui/html/states.html
@@ -1,5 +1,11 @@
{{ define "main" }}
<main class="responsive" id="main">
+ <a href="/states/new">
+ <button class="small-round">
+ <i>add</i>
+ <span>New</span>
+ </button>
+ </a>
<table class="clickable-rows no-space">
<thead>
<tr>
diff --git a/pkg/webui/html/states_new.html b/pkg/webui/html/states_new.html
new file mode 100644
index 0000000..68facc7
--- /dev/null
+++ b/pkg/webui/html/states_new.html
@@ -0,0 +1,33 @@
+{{ define "main" }}
+<main class="responsive">
+ <form action="/states/new" enctype="multipart/form-data" method="post">
+ <fieldset>
+ <div class="field border label{{ if .PathError }} invalid{{ end }}">
+ <input autofocus
+ id="path"
+ name="path"
+ required
+ type="text"
+ value="{{ .Path }}">
+ <label for="path">Path</label>
+ {{ if .PathDuplicate }}
+ <span class="error">This path already exist</span>
+ {{ else if .PathError }}
+ <span class="error">Invalid path</span>
+ {{ else }}
+ <span class="helper">Valid URL path beginning with a /</span>
+ {{ end }}
+ </div>
+ <div class="field label border">
+ <input name="file"
+ required
+ type="file">
+ <input type="text">
+ <label>File</label>
+ <span class="helper">JSON state file</span>
+ </div>
+ <button class="small-round" type="submit" value="submit">New</button>
+ </fieldset>
+ </form>
+</main>
+{{ end }}
diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go
index e84f33a..7ee7841 100644
--- a/pkg/webui/routes.go
+++ b/pkg/webui/routes.go
@@ -19,6 +19,8 @@ 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 /states/new", requireLogin(handleStatesNewGET(db)))
+ mux.Handle("POST /states/new", requireLogin(handleStatesNewPOST(db)))
mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db)))
mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
mux.Handle("GET /version/{id}", requireLogin(handleVersionGET(db)))
diff --git a/pkg/webui/states_new.go b/pkg/webui/states_new.go
new file mode 100644
index 0000000..8551191
--- /dev/null
+++ b/pkg/webui/states_new.go
@@ -0,0 +1,84 @@
+package webui
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+type StatesNewPage struct {
+ Page *Page
+ fileError bool
+ Path string
+ PathDuplicate bool
+ PathError bool
+}
+
+var statesNewTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states_new.html"))
+
+func handleStatesNewGET(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ render(w, statesNewTemplates, http.StatusOK, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ })
+ })
+}
+
+func handleStatesNewPOST(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // file upload limit of 20MB
+ if err := r.ParseMultipartForm(20 << 20); err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+ statePath := r.FormValue("path")
+ parsedStatePath, err := url.Parse(statePath)
+ if err != nil || path.Clean(parsedStatePath.Path) != statePath || statePath[0] != '/' {
+ render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ Path: statePath,
+ PathError: true,
+ })
+ return
+ }
+ data, err := io.ReadAll(file)
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, fmt.Errorf("failed to read uploaded file: %w", err))
+ return
+ }
+ fileType := http.DetectContentType(data)
+ if fileType != "text/plain; charset=utf-8" {
+ errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid file type: expected \"text/plain; charset=utf-8\" but got \"%s\"", fileType))
+ return
+ }
+ account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
+ version, err := db.CreateState(statePath, account.Id, data)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if version == nil {
+ render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ Path: statePath,
+ PathDuplicate: true,
+ })
+ return
+ }
+ destination := path.Join("/version", strconv.Itoa(version.Id))
+ http.Redirect(w, r, destination, http.StatusFound)
+ })
+}