diff options
Diffstat (limited to '')
-rw-r--r-- | pkg/webui/cache.go | 10 | ||||
-rw-r--r-- | pkg/webui/error.go | 23 | ||||
-rw-r--r-- | pkg/webui/healthz.go (renamed from cmd/tfstated/healthz.go) | 2 | ||||
-rw-r--r-- | pkg/webui/html/base.html | 58 | ||||
-rw-r--r-- | pkg/webui/html/error.html | 6 | ||||
-rw-r--r-- | pkg/webui/html/login.html | 27 | ||||
-rw-r--r-- | pkg/webui/html/logout.html | 5 | ||||
-rw-r--r-- | pkg/webui/html/settings.html | 25 | ||||
-rw-r--r-- | pkg/webui/html/state.html | 30 | ||||
-rw-r--r-- | pkg/webui/html/states.html | 38 | ||||
-rw-r--r-- | pkg/webui/html/states_new.html | 33 | ||||
-rw-r--r-- | pkg/webui/html/version.html | 10 | ||||
-rw-r--r-- | pkg/webui/index.go | 31 | ||||
-rw-r--r-- | pkg/webui/login.go | 126 | ||||
-rw-r--r-- | pkg/webui/logout.go | 29 | ||||
-rw-r--r-- | pkg/webui/render.go | 22 | ||||
-rw-r--r-- | pkg/webui/routes.go | 28 | ||||
-rw-r--r-- | pkg/webui/run.go | 52 | ||||
-rw-r--r-- | pkg/webui/sessions.go | 61 | ||||
-rw-r--r-- | pkg/webui/settings.go | 53 | ||||
-rw-r--r-- | pkg/webui/state.go | 54 | ||||
-rw-r--r-- | pkg/webui/states.go | 29 | ||||
-rw-r--r-- | pkg/webui/states_new.go | 84 | ||||
-rw-r--r-- | pkg/webui/static/favicon.svg | 3 | ||||
-rw-r--r-- | pkg/webui/static/main.css | 8 | ||||
-rw-r--r-- | pkg/webui/version.go | 62 |
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, + }) + }) +} |