summaryrefslogtreecommitdiff
path: root/pkg/webui
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkg/webui/cache.go10
-rw-r--r--pkg/webui/error.go23
-rw-r--r--pkg/webui/healthz.go (renamed from cmd/tfstated/healthz.go)2
-rw-r--r--pkg/webui/html/base.html58
-rw-r--r--pkg/webui/html/error.html6
-rw-r--r--pkg/webui/html/login.html27
-rw-r--r--pkg/webui/html/logout.html5
-rw-r--r--pkg/webui/html/settings.html25
-rw-r--r--pkg/webui/html/state.html30
-rw-r--r--pkg/webui/html/states.html38
-rw-r--r--pkg/webui/html/states_new.html33
-rw-r--r--pkg/webui/html/version.html10
-rw-r--r--pkg/webui/index.go31
-rw-r--r--pkg/webui/login.go126
-rw-r--r--pkg/webui/logout.go29
-rw-r--r--pkg/webui/render.go22
-rw-r--r--pkg/webui/routes.go28
-rw-r--r--pkg/webui/run.go52
-rw-r--r--pkg/webui/sessions.go61
-rw-r--r--pkg/webui/settings.go53
-rw-r--r--pkg/webui/state.go54
-rw-r--r--pkg/webui/states.go29
-rw-r--r--pkg/webui/states_new.go84
-rw-r--r--pkg/webui/static/favicon.svg3
-rw-r--r--pkg/webui/static/main.css8
-rw-r--r--pkg/webui/version.go62
26 files changed, 908 insertions, 1 deletions
diff --git a/pkg/webui/cache.go b/pkg/webui/cache.go
new file mode 100644
index 0000000..cef999b
--- /dev/null
+++ b/pkg/webui/cache.go
@@ -0,0 +1,10 @@
+package webui
+
+import "net/http"
+
+func cache(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/pkg/webui/error.go b/pkg/webui/error.go
new file mode 100644
index 0000000..afce9a6
--- /dev/null
+++ b/pkg/webui/error.go
@@ -0,0 +1,23 @@
+package webui
+
+import (
+ "html/template"
+ "net/http"
+)
+
+var errorTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/error.html"))
+
+func errorResponse(w http.ResponseWriter, status int, err error) {
+ type ErrorData struct {
+ Page
+ Err error
+ Status int
+ StatusText string
+ }
+ render(w, errorTemplates, status, &ErrorData{
+ Page: Page{Title: "Error", Section: "error"},
+ Err: err,
+ Status: status,
+ StatusText: http.StatusText(status),
+ })
+}
diff --git a/cmd/tfstated/healthz.go b/pkg/webui/healthz.go
index 20c72c9..dee51d0 100644
--- a/cmd/tfstated/healthz.go
+++ b/pkg/webui/healthz.go
@@ -1,4 +1,4 @@
-package main
+package webui
import "net/http"
diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html
new file mode 100644
index 0000000..4ec6565
--- /dev/null
+++ b/pkg/webui/html/base.html
@@ -0,0 +1,58 @@
+{{ define "nav" }}
+<header>
+ <nav>
+ <a href="/">
+ <h6>TFSTATED</h6>
+ </a>
+ </nav>
+</header>
+{{ if eq .Page.Section "login" }}
+<a href="/login" class="active">
+ <i>login</i>
+ <span>Login</span>
+</a>
+{{ else }}
+<a href="/states"{{ if eq .Page.Section "states" }} class="fill"{{ end}}>
+ <i>home_storage</i>
+ <span>States</span>
+</a>
+<a href="/settings"{{ if eq .Page.Section "settings" }} class="fill"{{ end}}>
+ <i>settings</i>
+ <span>Settings</span>
+</a>
+<a href="/logout">
+ <i>logout</i>
+ <span>Logout</span>
+</a>
+{{ end }}
+{{ end }}
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" href="/static/favicon.svg">
+ <link href="/static/main.css" rel="stylesheet">
+ <link href="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.css" rel="stylesheet">
+ <title>TFSTATED - {{ .Page.Title }}</title>
+ </head>
+ <body class="{{ if .Page.LightMode }}light{{ else }}dark{{ end }}">
+ <nav class="left drawer l">{{ template "nav" . }}</nav>
+ <nav class="left m">{{ template "nav" . }}</nav>
+ <nav class="bottom s">{{ template "nav" . }}</nav>
+ <header>
+ <nav>
+ {{ if ne .Page.Precedent "" }}
+ <a href="{{ .Page.Precedent }}" class="button circle chip">
+ <i>arrow_back</i>
+ </a>
+ {{ end }}
+ <h5 class="max center-align">{{ .Page.Title }}</h5>
+ </nav>
+ </header>
+ {{ template "main" . }}
+ <footer>
+ </footer>
+ <script type="module" src="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.js"></script>
+ </body>
+</html>
diff --git a/pkg/webui/html/error.html b/pkg/webui/html/error.html
new file mode 100644
index 0000000..cda21f6
--- /dev/null
+++ b/pkg/webui/html/error.html
@@ -0,0 +1,6 @@
+{{ define "main" }}
+<main class="responsive">
+<h5>{{ .Status }} - {{ .StatusText }}</h5>
+<p>{{ .Err }}</p>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/login.html b/pkg/webui/html/login.html
new file mode 100644
index 0000000..deb6d4a
--- /dev/null
+++ b/pkg/webui/html/login.html
@@ -0,0 +1,27 @@
+{{ define "main" }}
+<main class="responsive">
+ <form action="/login" method="post">
+ <fieldset>
+ <div class="field border label{{ if .Forbidden }} invalid{{ end}}">
+ <input autofocus
+ id="username"
+ name="username"
+ type="text"
+ value="{{ .Username }}"
+ required>
+ <label for="username">Username</label>
+ {{ if .Forbidden }}<span class="error">Invalid username or password</span>{{ end }}
+ </div>
+ <div class="field border label{{ if .Forbidden }} invalid{{ end}}">
+ <input id="password"
+ name="password"
+ type="password"
+ required>
+ <label for="password">Password</label>
+ {{ if .Forbidden }}<span class="error">Invalid username or password</span>{{ end }}
+ </div>
+ <button class="small-round" type="submit" value="login">Login</button>
+ </fieldset>
+ </form>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/logout.html b/pkg/webui/html/logout.html
new file mode 100644
index 0000000..e9203d4
--- /dev/null
+++ b/pkg/webui/html/logout.html
@@ -0,0 +1,5 @@
+{{ define "main" }}
+<main class="responsive">
+ <h5>Logout successful</h5>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/settings.html b/pkg/webui/html/settings.html
new file mode 100644
index 0000000..4040b9b
--- /dev/null
+++ b/pkg/webui/html/settings.html
@@ -0,0 +1,25 @@
+{{ define "main" }}
+<main class="responsive">
+ <form action="/settings" method="post">
+ <fieldset>
+ <div class="field middle-align">
+ <nav>
+ <div class="max">
+ <h6>Dark Mode</h6>
+ </div>
+ <label class="switch icon">
+ <input {{ if not .Settings.LightMode }} checked{{ end }}
+ name="dark-mode"
+ type="checkbox"
+ value="1" />
+ <span>
+ <i>dark_mode</i>
+ </span>
+ </label>
+ </nav>
+ </div>
+ <button class="small-round" type="submit" value="login">Save</button>
+ </fieldset>
+ </form>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/state.html b/pkg/webui/html/state.html
new file mode 100644
index 0000000..4439d9e
--- /dev/null
+++ b/pkg/webui/html/state.html
@@ -0,0 +1,30 @@
+{{ define "main" }}
+<main class="responsive" id="main">
+ <p>
+ Locked:
+ {{ if eq .State.Lock nil }}no{{ else }}
+ <span>yes</span>
+ <div class="tooltip left max">
+ <b>Lock</b>
+ <p>{{ .State.Lock }}</p>
+ </div>
+ {{ end }}
+ </p>
+ <table class="clickable-rows no-space">
+ <thead>
+ <tr>
+ <th>By</th>
+ <th>Created</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range .Versions }}
+ <tr>
+ <td><a href="/version/{{ .Id }}">{{ index $.Usernames .AccountId }}</a></td>
+ <td><a href="/version/{{ .Id }}">{{ .Created }}</a></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/states.html b/pkg/webui/html/states.html
new file mode 100644
index 0000000..37d80cf
--- /dev/null
+++ b/pkg/webui/html/states.html
@@ -0,0 +1,38 @@
+{{ define "main" }}
+<main class="responsive" id="main">
+ <a href="/states/new">
+ <button class="small-round">
+ <i>add</i>
+ <span>New</span>
+ </button>
+ </a>
+ <table class="clickable-rows no-space">
+ <thead>
+ <tr>
+ <th>Path</th>
+ <th>Updated</th>
+ <th>Locked</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range .States }}
+ <tr>
+ <td><a href="/state/{{ .Id }}">{{ .Path }}</a></td>
+ <td><a href="/state/{{ .Id }}">{{ .Updated }}</a></td>
+ <td>
+ <a href="/state/{{ .Id }}">
+ {{ if eq .Lock nil }}no{{ else }}
+ <span>yes</span>
+ <div class="tooltip left max">
+ <b>Lock</b>
+ <p>{{ .Lock }}</p>
+ </div>
+ {{ end }}
+ </a>
+ </td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/states_new.html b/pkg/webui/html/states_new.html
new file mode 100644
index 0000000..68facc7
--- /dev/null
+++ b/pkg/webui/html/states_new.html
@@ -0,0 +1,33 @@
+{{ define "main" }}
+<main class="responsive">
+ <form action="/states/new" enctype="multipart/form-data" method="post">
+ <fieldset>
+ <div class="field border label{{ if .PathError }} invalid{{ end }}">
+ <input autofocus
+ id="path"
+ name="path"
+ required
+ type="text"
+ value="{{ .Path }}">
+ <label for="path">Path</label>
+ {{ if .PathDuplicate }}
+ <span class="error">This path already exist</span>
+ {{ else if .PathError }}
+ <span class="error">Invalid path</span>
+ {{ else }}
+ <span class="helper">Valid URL path beginning with a /</span>
+ {{ end }}
+ </div>
+ <div class="field label border">
+ <input name="file"
+ required
+ type="file">
+ <input type="text">
+ <label>File</label>
+ <span class="helper">JSON state file</span>
+ </div>
+ <button class="small-round" type="submit" value="submit">New</button>
+ </fieldset>
+ </form>
+</main>
+{{ end }}
diff --git a/pkg/webui/html/version.html b/pkg/webui/html/version.html
new file mode 100644
index 0000000..b849783
--- /dev/null
+++ b/pkg/webui/html/version.html
@@ -0,0 +1,10 @@
+{{ define "main" }}
+<main class="responsive" id="main">
+ <p>
+ Created by
+ <a href="/users/{{ .Account.Id }}">{{ .Account.Username }}</a>
+ at {{ .Version.Created }}
+ </p>
+ <pre>{{ .VersionData }}</pre>
+</main>
+{{ end }}
diff --git a/pkg/webui/index.go b/pkg/webui/index.go
new file mode 100644
index 0000000..1168098
--- /dev/null
+++ b/pkg/webui/index.go
@@ -0,0 +1,31 @@
+package webui
+
+import (
+ "fmt"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+type Page struct {
+ LightMode bool
+ Precedent string
+ Section string
+ Title string
+}
+
+func makePage(r *http.Request, page *Page) *Page {
+ settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
+ page.LightMode = settings.LightMode
+ return page
+}
+
+func handleIndexGET() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/" {
+ http.Redirect(w, r, "/states", http.StatusFound)
+ } else {
+ errorResponse(w, http.StatusNotFound, fmt.Errorf("Page not found"))
+ }
+ })
+}
diff --git a/pkg/webui/login.go b/pkg/webui/login.go
new file mode 100644
index 0000000..18864b2
--- /dev/null
+++ b/pkg/webui/login.go
@@ -0,0 +1,126 @@
+package webui
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "regexp"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+var loginTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/login.html"))
+
+type loginPage struct {
+ Page
+ Forbidden bool
+ Username string
+}
+
+func handleLoginGET() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store, no-cache")
+
+ session := r.Context().Value(model.SessionContextKey{})
+ if session != nil {
+ http.Redirect(w, r, "/states", http.StatusFound)
+ return
+ }
+
+ render(w, loginTemplate, http.StatusOK, loginPage{
+ Page: Page{Title: "Login", Section: "login"},
+ })
+ })
+}
+
+func handleLoginPOST(db *database.DB) http.Handler {
+ var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`)
+ renderForbidden := func(w http.ResponseWriter, username string) {
+ render(w, loginTemplate, http.StatusForbidden, loginPage{
+ Page: Page{Title: "Login", Section: "login"},
+ Forbidden: true,
+ Username: username,
+ })
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if username == "" || password == "" { // the webui cannot issue this
+ errorResponse(w, http.StatusBadRequest, fmt.Errorf("Forbidden"))
+ return
+ }
+ if ok := validUsername.MatchString(username); !ok {
+ renderForbidden(w, username)
+ return
+ }
+ account, err := db.LoadAccountByUsername(username)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if account == nil || !account.CheckPassword(password) {
+ renderForbidden(w, username)
+ return
+ }
+ if err := db.TouchAccount(account); err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ sessionId, err := db.CreateSession(account)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: sessionId,
+ Quoted: false,
+ Path: "/",
+ MaxAge: 8 * 3600, // 1 hour sessions
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ Secure: true,
+ })
+ http.Redirect(w, r, "/", http.StatusFound)
+ })
+}
+
+func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store, no-cache")
+ session := r.Context().Value(model.SessionContextKey{})
+ if session == nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ account, err := db.LoadAccountById(session.(*model.Session).AccountId)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if account == nil {
+ // this could happen if the account was deleted in the short
+ // time between retrieving the session and here
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account)
+ var settings model.Settings
+ if err := json.Unmarshal(account.Settings, &settings); err != nil {
+ slog.Error("failed to unmarshal account settings", "err", err, "accountId", account.Id)
+ }
+ ctx = context.WithValue(ctx, model.SettingsContextKey{}, &settings)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }))
+ }
+}
diff --git a/pkg/webui/logout.go b/pkg/webui/logout.go
new file mode 100644
index 0000000..58e445d
--- /dev/null
+++ b/pkg/webui/logout.go
@@ -0,0 +1,29 @@
+package webui
+
+import (
+ "html/template"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+var logoutTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/logout.html"))
+
+func handleLogoutGET(db *database.DB) http.Handler {
+ type logoutPage struct {
+ Page
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value(model.SessionContextKey{})
+ err := db.DeleteSession(session.(*model.Session))
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ unsetSesssionCookie(w)
+ render(w, logoutTemplate, http.StatusOK, logoutPage{
+ Page: Page{Title: "Logout", Section: "login"},
+ })
+ })
+}
diff --git a/pkg/webui/render.go b/pkg/webui/render.go
new file mode 100644
index 0000000..23f5e51
--- /dev/null
+++ b/pkg/webui/render.go
@@ -0,0 +1,22 @@
+package webui
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "net/http"
+)
+
+func render(w http.ResponseWriter, t *template.Template, status int, data any) {
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(fmt.Sprintf(
+ "%s: failed to execute template: %+v",
+ http.StatusText(http.StatusInternalServerError),
+ err)))
+ } else {
+ w.WriteHeader(status)
+ _, _ = buf.WriteTo(w)
+ }
+}
diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go
new file mode 100644
index 0000000..7ee7841
--- /dev/null
+++ b/pkg/webui/routes.go
@@ -0,0 +1,28 @@
+package webui
+
+import (
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+)
+
+func addRoutes(
+ mux *http.ServeMux,
+ db *database.DB,
+) {
+ requireSession := sessionsMiddleware(db)
+ requireLogin := loginMiddleware(db, requireSession)
+ mux.Handle("GET /healthz", handleHealthz())
+ mux.Handle("GET /login", requireSession(handleLoginGET()))
+ mux.Handle("POST /login", requireSession(handleLoginPOST(db)))
+ mux.Handle("GET /logout", requireLogin(handleLogoutGET(db)))
+ mux.Handle("GET /settings", requireLogin(handleSettingsGET(db)))
+ mux.Handle("POST /settings", requireLogin(handleSettingsPOST(db)))
+ mux.Handle("GET /states", requireLogin(handleStatesGET(db)))
+ mux.Handle("GET /states/new", requireLogin(handleStatesNewGET(db)))
+ mux.Handle("POST /states/new", requireLogin(handleStatesNewPOST(db)))
+ mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db)))
+ mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
+ mux.Handle("GET /version/{id}", requireLogin(handleVersionGET(db)))
+ mux.Handle("GET /", requireLogin(handleIndexGET()))
+}
diff --git a/pkg/webui/run.go b/pkg/webui/run.go
new file mode 100644
index 0000000..664b9e5
--- /dev/null
+++ b/pkg/webui/run.go
@@ -0,0 +1,52 @@
+package webui
+
+import (
+ "context"
+ "embed"
+ "log/slog"
+ "net"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger"
+)
+
+//go:embed html/*
+var htmlFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+func Run(
+ ctx context.Context,
+ db *database.DB,
+ getenv func(string) string,
+) *http.Server {
+ mux := http.NewServeMux()
+ addRoutes(
+ mux,
+ db,
+ )
+
+ host := getenv("TFSTATED_WEBUI_HOST")
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ port := getenv("TFSTATED_WEBUI_PORT")
+ if port == "" {
+ port = "8081"
+ }
+
+ httpServer := &http.Server{
+ Addr: net.JoinHostPort(host, port),
+ Handler: logger.Middleware(mux, false),
+ }
+ go func() {
+ slog.Info("webui http server listening", "address", httpServer.Addr)
+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ slog.Error("error listening and serving webui http server", "address", httpServer.Addr, "error", err)
+ }
+ }()
+
+ return httpServer
+}
diff --git a/pkg/webui/sessions.go b/pkg/webui/sessions.go
new file mode 100644
index 0000000..7a2fd02
--- /dev/null
+++ b/pkg/webui/sessions.go
@@ -0,0 +1,61 @@
+package webui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+const cookieName = "tfstated"
+
+func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie(cookieName)
+ if err != nil && !errors.Is(err, http.ErrNoCookie) {
+ errorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to get request cookie \"%s\": %w", cookieName, err))
+ return
+ }
+ if err == nil {
+ if len(cookie.Value) != 36 {
+ unsetSesssionCookie(w)
+ } else {
+ session, err := db.LoadSessionById(cookie.Value)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if session == nil {
+ unsetSesssionCookie(w)
+ } else if !session.IsExpired() {
+ if err := db.TouchSession(cookie.Value); err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func unsetSesssionCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: "",
+ Quoted: false,
+ Path: "/",
+ MaxAge: 0, // remove invalid cookie
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ Secure: true,
+ })
+}
diff --git a/pkg/webui/settings.go b/pkg/webui/settings.go
new file mode 100644
index 0000000..eb0910f
--- /dev/null
+++ b/pkg/webui/settings.go
@@ -0,0 +1,53 @@
+package webui
+
+import (
+ "html/template"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+type SettingsPage struct {
+ Page *Page
+ Settings *model.Settings
+}
+
+var settingsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/settings.html"))
+
+func handleSettingsGET(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
+ render(w, settingsTemplates, http.StatusOK, SettingsPage{
+ Page: makePage(r, &Page{Title: "Settings", Section: "settings"}),
+ Settings: settings,
+ })
+ })
+}
+
+func handleSettingsPOST(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ darkMode := r.FormValue("dark-mode")
+ settings := model.Settings{
+ LightMode: darkMode != "1",
+ }
+ account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
+ err := db.SaveAccountSettings(account, &settings)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ render(w, settingsTemplates, http.StatusOK, SettingsPage{
+ Page: &Page{
+ LightMode: settings.LightMode,
+ Title: "Settings",
+ Section: "settings",
+ },
+ Settings: &settings,
+ })
+ })
+}
diff --git a/pkg/webui/state.go b/pkg/webui/state.go
new file mode 100644
index 0000000..2ad1597
--- /dev/null
+++ b/pkg/webui/state.go
@@ -0,0 +1,54 @@
+package webui
+
+import (
+ "html/template"
+ "net/http"
+ "strconv"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+var stateTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/state.html"))
+
+func handleStateGET(db *database.DB) http.Handler {
+ type StatesData struct {
+ Page *Page
+ State *model.State
+ Usernames map[string]string
+ Versions []model.Version
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ stateIdStr := r.PathValue("id")
+ stateId, err := strconv.Atoi(stateIdStr)
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ state, err := db.LoadStateById(stateId)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ versions, err := db.LoadVersionsByState(state)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ usernames, err := db.LoadAccountUsernames()
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ render(w, stateTemplate, http.StatusOK, StatesData{
+ Page: makePage(r, &Page{
+ Precedent: "/states",
+ Section: "states",
+ Title: state.Path,
+ }),
+ State: state,
+ Usernames: usernames,
+ Versions: versions,
+ })
+ })
+}
diff --git a/pkg/webui/states.go b/pkg/webui/states.go
new file mode 100644
index 0000000..a0d16ca
--- /dev/null
+++ b/pkg/webui/states.go
@@ -0,0 +1,29 @@
+package webui
+
+import (
+ "html/template"
+ "net/http"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states.html"))
+
+func handleStatesGET(db *database.DB) http.Handler {
+ type StatesData struct {
+ Page *Page
+ States []model.State
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ states, err := db.LoadStates()
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ render(w, statesTemplates, http.StatusOK, StatesData{
+ Page: makePage(r, &Page{Title: "States", Section: "states"}),
+ States: states,
+ })
+ })
+}
diff --git a/pkg/webui/states_new.go b/pkg/webui/states_new.go
new file mode 100644
index 0000000..8551191
--- /dev/null
+++ b/pkg/webui/states_new.go
@@ -0,0 +1,84 @@
+package webui
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+type StatesNewPage struct {
+ Page *Page
+ fileError bool
+ Path string
+ PathDuplicate bool
+ PathError bool
+}
+
+var statesNewTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/states_new.html"))
+
+func handleStatesNewGET(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ render(w, statesNewTemplates, http.StatusOK, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ })
+ })
+}
+
+func handleStatesNewPOST(db *database.DB) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // file upload limit of 20MB
+ if err := r.ParseMultipartForm(20 << 20); err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+ statePath := r.FormValue("path")
+ parsedStatePath, err := url.Parse(statePath)
+ if err != nil || path.Clean(parsedStatePath.Path) != statePath || statePath[0] != '/' {
+ render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ Path: statePath,
+ PathError: true,
+ })
+ return
+ }
+ data, err := io.ReadAll(file)
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, fmt.Errorf("failed to read uploaded file: %w", err))
+ return
+ }
+ fileType := http.DetectContentType(data)
+ if fileType != "text/plain; charset=utf-8" {
+ errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid file type: expected \"text/plain; charset=utf-8\" but got \"%s\"", fileType))
+ return
+ }
+ account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
+ version, err := db.CreateState(statePath, account.Id, data)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if version == nil {
+ render(w, statesNewTemplates, http.StatusBadRequest, StatesNewPage{
+ Page: makePage(r, &Page{Title: "New State", Section: "states"}),
+ Path: statePath,
+ PathDuplicate: true,
+ })
+ return
+ }
+ destination := path.Join("/version", strconv.Itoa(version.Id))
+ http.Redirect(w, r, destination, http.StatusFound)
+ })
+}
diff --git a/pkg/webui/static/favicon.svg b/pkg/webui/static/favicon.svg
new file mode 100644
index 0000000..56f9365
--- /dev/null
+++ b/pkg/webui/static/favicon.svg
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(0, 0, 0);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="98px" height="94px" viewBox="-0.5 -0.5 98 94"><defs/><rect fill="#000000" width="100%" height="100%" x="0" y="0"/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="d8vDQkJiZMsKFp_Zf0So-1"><g><rect x="0" y="0" width="98" height="94" fill="none" stroke="none" pointer-events="all"/><path d="M 89.23 80.02 L 89.23 31.87 L 76.66 31.87 L 76.66 80.02 L 66.61 80.02 L 66.61 31.87 L 54.04 31.87 L 54.04 80.02 L 43.99 80.02 L 43.99 31.87 L 31.42 31.87 L 31.42 80.02 L 21.36 80.02 L 21.36 31.87 L 8.8 31.87 L 8.8 80.02 L 8.79 80.02 L 0 94 L 98 93.98 Z M 2.49 26.57 L 95.51 26.57 L 49 0 Z" fill="#e6e6e6" stroke="none" pointer-events="all"/></g></g></g></g></g></svg> \ No newline at end of file
diff --git a/pkg/webui/static/main.css b/pkg/webui/static/main.css
new file mode 100644
index 0000000..ae56cd0
--- /dev/null
+++ b/pkg/webui/static/main.css
@@ -0,0 +1,8 @@
+table tbody a {
+ display: block;
+ text-decoration: none;
+ transition: all 0.25s ease-out;
+}
+.clickable-rows tbody tr:hover a {
+ background-color: var(--secondary-container);
+}
diff --git a/pkg/webui/version.go b/pkg/webui/version.go
new file mode 100644
index 0000000..a577d5f
--- /dev/null
+++ b/pkg/webui/version.go
@@ -0,0 +1,62 @@
+package webui
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "strconv"
+
+ "git.adyxax.org/adyxax/tfstated/pkg/database"
+ "git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+var versionTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/version.html"))
+
+func handleVersionGET(db *database.DB) http.Handler {
+ type VersionsData struct {
+ Page *Page
+ Account *model.Account
+ State *model.State
+ Version *model.Version
+ VersionData string
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ versionIdStr := r.PathValue("id")
+ versionId, err := strconv.Atoi(versionIdStr)
+ if err != nil {
+ errorResponse(w, http.StatusBadRequest, err)
+ return
+ }
+ version, err := db.LoadVersionById(versionId)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ if version == nil {
+ errorResponse(w, http.StatusNotFound, err)
+ return
+ }
+ state, err := db.LoadStateById(version.StateId)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ account, err := db.LoadAccountById(version.AccountId)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err)
+ return
+ }
+ versionData := string(version.Data[:])
+ render(w, versionTemplate, http.StatusOK, VersionsData{
+ Page: makePage(r, &Page{
+ Precedent: fmt.Sprintf("/state/%d", state.Id),
+ Section: "states",
+ Title: state.Path,
+ }),
+ Account: account,
+ State: state,
+ Version: version,
+ VersionData: versionData,
+ })
+ })
+}