git ignore
This commit is contained in:
commit
3d302ee541
|
|
@ -0,0 +1,418 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"satoru/internal/store"
|
||||
"satoru/internal/webui"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "satoru_session"
|
||||
sessionTTL = 24 * time.Hour * 14
|
||||
)
|
||||
|
||||
type app struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dbPath := filepath.Join("data", "satoru.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
a := &app{store: st}
|
||||
r := chi.NewRouter()
|
||||
|
||||
fileServer := http.FileServer(http.Dir("web/static"))
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
|
||||
|
||||
r.Get("/", a.handleHome)
|
||||
r.Get("/account/password", a.handlePasswordPage)
|
||||
r.Post("/account/password", a.handlePasswordSubmit)
|
||||
r.Post("/sites", a.handleSiteCreate)
|
||||
r.Post("/sites/{id}/run", a.handleSiteRun)
|
||||
r.Get("/signup", a.handleSignupPage)
|
||||
r.Post("/signup", a.handleSignupSubmit)
|
||||
r.Get("/signin", a.handleSigninPage)
|
||||
r.Post("/signin", a.handleSigninSubmit)
|
||||
r.Post("/signout", a.handleSignoutSubmit)
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("satoru listening on http://localhost%s", addr)
|
||||
if err := http.ListenAndServe(addr, r); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.currentUser(r.Context(), r)
|
||||
if err != nil {
|
||||
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sites, err := a.store.ListSites(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load sites", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := webui.DashboardData{
|
||||
Now: time.Now(),
|
||||
User: user,
|
||||
Sites: sites,
|
||||
RuntimeChecks: runtimeChecks(),
|
||||
FlashMessage: r.URL.Query().Get("msg"),
|
||||
WorkflowStages: defaultWorkflowStages(),
|
||||
}
|
||||
templ.Handler(webui.Dashboard(data)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUser(r.Context(), r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
templ.Handler(webui.Signup(webui.AuthPageData{})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *app) handleSignupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := normalizeUsername(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
form := webui.AuthPageData{Username: username}
|
||||
if !validUsername(username) {
|
||||
form.Error = "Username must be 3-32 chars using letters, numbers, ., _, or -."
|
||||
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
form.Error = "Password must be at least 8 characters."
|
||||
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.store.CreateUser(r.Context(), username, string(hashBytes))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUsernameTaken) {
|
||||
form.Error = "That username is already registered."
|
||||
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to create account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUser(r.Context(), r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
templ.Handler(webui.Signin(webui.AuthPageData{})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *app) handleSigninSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := normalizeUsername(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
form := webui.AuthPageData{Username: username}
|
||||
|
||||
user, err := a.store.UserByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
form.Error = "Invalid username or password."
|
||||
templ.Handler(webui.Signin(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to sign in", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
form.Error = "Invalid username or password."
|
||||
templ.Handler(webui.Signin(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSignoutSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err == nil && c.Value != "" {
|
||||
_ = a.store.DeleteSessionByTokenHash(r.Context(), hashToken(c.Value))
|
||||
}
|
||||
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.currentUser(r.Context(), r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
templ.Handler(webui.ChangePassword(webui.PasswordPageData{User: user})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.currentUser(r.Context(), r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
current := r.FormValue("current_password")
|
||||
next := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
form := webui.PasswordPageData{User: user}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||
form.Error = "Current password is incorrect."
|
||||
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if len(next) < 8 {
|
||||
form.Error = "New password must be at least 8 characters."
|
||||
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if next != confirm {
|
||||
form.Error = "New password and confirmation do not match."
|
||||
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hashBytes, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to update password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := a.store.UpdateUserPasswordHash(r.Context(), user.ID, string(hashBytes)); err != nil {
|
||||
http.Error(w, "failed to update password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/?msg=password-updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUser(r.Context(), r); err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
remotePath := strings.TrimSpace(r.FormValue("remote_path"))
|
||||
if sshUser == "" || host == "" || remotePath == "" {
|
||||
http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.store.CreateSite(r.Context(), sshUser, host, remotePath); err != nil {
|
||||
http.Error(w, "failed to add site", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUser(r.Context(), r); err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid site id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
site, err := a.store.SiteByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to load site", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
status, output := runSSHHello(r.Context(), site)
|
||||
if err := a.store.UpdateSiteRunResult(r.Context(), site.ID, status, output, time.Now()); err != nil {
|
||||
http.Error(w, "failed to store run result", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?msg=site-ran", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(sessionTTL)
|
||||
if err := a.store.CreateSession(r.Context(), userID, hashToken(token), expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return store.User{}, http.ErrNoCookie
|
||||
}
|
||||
return a.store.UserBySessionTokenHash(ctx, hashToken(c.Value))
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`)
|
||||
|
||||
func normalizeUsername(v string) string {
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func validUsername(v string) bool {
|
||||
return usernamePattern.MatchString(v)
|
||||
}
|
||||
|
||||
func runtimeChecks() []webui.RuntimeCheck {
|
||||
tools := []string{"restic", "rsync", "ssh"}
|
||||
out := make([]webui.RuntimeCheck, 0, len(tools))
|
||||
for _, name := range tools {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"})
|
||||
continue
|
||||
}
|
||||
out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: path})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSSHHello(ctx context.Context, site store.Site) (string, string) {
|
||||
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "ssh", target, "echo hello from satoru")
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := strings.TrimSpace(string(out))
|
||||
if output == "" {
|
||||
output = "(no output)"
|
||||
}
|
||||
if err != nil {
|
||||
return "failed", output
|
||||
}
|
||||
return "ok", output
|
||||
}
|
||||
|
||||
func defaultWorkflowStages() []webui.WorkflowStage {
|
||||
return []webui.WorkflowStage{
|
||||
{Title: "Pull from Edge over SSH", Description: "Satoru connects to Linux edge hosts using local keys and pulls approved paths."},
|
||||
{Title: "Stage on Backup Server", Description: "Pulled data lands on the backup host first, keeping edge systems isolated from B2."},
|
||||
{Title: "Restic to B2", Description: "Restic runs centrally on this Satoru instance and uploads snapshots to Backblaze B2."},
|
||||
{Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
module satoru
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.977
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
golang.org/x/crypto v0.47.0
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
IsAdmin bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Site struct {
|
||||
ID int64
|
||||
SSHUser string
|
||||
Host string
|
||||
RemotePath string
|
||||
CreatedAt time.Time
|
||||
LastRunStatus sql.NullString
|
||||
LastRunOutput sql.NullString
|
||||
LastRunAt sql.NullTime
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
const usersSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sessionsSQL = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);`
|
||||
|
||||
const sitesSQL = `
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ssh_user TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
remote_path TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_run_status TEXT,
|
||||
last_run_output TEXT,
|
||||
last_run_at DATETIME
|
||||
);`
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, usersSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateUsersLegacyEmail(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) migrateUsersLegacyEmail(ctx context.Context) error {
|
||||
cols, err := tableColumns(ctx, s.db, "users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cols["username"] {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN username TEXT`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cols["email"] {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE users SET username = lower(trim(email)) WHERE username IS NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM users`).Scan(&count); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
isAdmin := count == 0
|
||||
|
||||
res, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`,
|
||||
username,
|
||||
passwordHash,
|
||||
boolToInt(isAdmin),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueUsernameErr(err) {
|
||||
return User{}, ErrUsernameTaken
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
userID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user, err := userByIDTx(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserByUsername(ctx context.Context, username string) (User, error) {
|
||||
const q = `
|
||||
SELECT id, username, password_hash, is_admin, created_at
|
||||
FROM users
|
||||
WHERE username = ?`
|
||||
return scanUser(s.db.QueryRowContext(ctx, q, username))
|
||||
}
|
||||
|
||||
func (s *Store) UserBySessionTokenHash(ctx context.Context, tokenHash string) (User, error) {
|
||||
const q = `
|
||||
SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > CURRENT_TIMESTAMP`
|
||||
return scanUser(s.db.QueryRowContext(ctx, q, tokenHash))
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
|
||||
_, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`,
|
||||
userID,
|
||||
tokenHash,
|
||||
expiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string) (Site, error) {
|
||||
res, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sites (ssh_user, host, remote_path) VALUES (?, ?, ?)`,
|
||||
sshUser,
|
||||
host,
|
||||
remotePath,
|
||||
)
|
||||
if err != nil {
|
||||
return Site{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Site{}, err
|
||||
}
|
||||
return s.SiteByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
|
||||
const q = `
|
||||
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
||||
FROM sites
|
||||
ORDER BY id DESC`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Site
|
||||
for rows.Next() {
|
||||
site, err := scanSite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, site)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) {
|
||||
const q = `
|
||||
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
||||
FROM sites
|
||||
WHERE id = ?`
|
||||
return scanSite(s.db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sites SET last_run_status = ?, last_run_output = ?, last_run_at = ? WHERE id = ?`,
|
||||
status,
|
||||
output,
|
||||
at.UTC().Format(time.RFC3339),
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) {
|
||||
const q = `
|
||||
SELECT id, username, password_hash, is_admin, created_at
|
||||
FROM users
|
||||
WHERE id = ?`
|
||||
return scanUser(tx.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanUser(row scanner) (User, error) {
|
||||
var user User
|
||||
var isAdmin int
|
||||
if err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &isAdmin, &user.CreatedAt); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.IsAdmin = isAdmin == 1
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func scanSite(row scanner) (Site, error) {
|
||||
var site Site
|
||||
if err := row.Scan(
|
||||
&site.ID,
|
||||
&site.SSHUser,
|
||||
&site.Host,
|
||||
&site.RemotePath,
|
||||
&site.CreatedAt,
|
||||
&site.LastRunStatus,
|
||||
&site.LastRunOutput,
|
||||
&site.LastRunAt,
|
||||
); err != nil {
|
||||
return Site{}, err
|
||||
}
|
||||
return site, nil
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var ErrUsernameTaken = errors.New("username already registered")
|
||||
|
||||
func isUniqueUsernameErr(err error) bool {
|
||||
return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username")
|
||||
}
|
||||
|
||||
func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) {
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var typ string
|
||||
var notNull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
type AuthPageData struct {
|
||||
Username string
|
||||
Error string
|
||||
}
|
||||
|
||||
func Signup(data AuthPageData) templ.Component {
|
||||
return authPage(
|
||||
"Sign up",
|
||||
"Create your account",
|
||||
"First account created becomes the initial admin.",
|
||||
"/signup",
|
||||
"Create account",
|
||||
"/signin",
|
||||
"Already have an account? Sign in",
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
func Signin(data AuthPageData) templ.Component {
|
||||
return authPage(
|
||||
"Sign in",
|
||||
"Welcome back",
|
||||
"Sign in to manage backup infrastructure.",
|
||||
"/signin",
|
||||
"Sign in",
|
||||
"/signup",
|
||||
"Need an account? Sign up",
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
func authPage(
|
||||
title string,
|
||||
heading string,
|
||||
description string,
|
||||
action string,
|
||||
submitLabel string,
|
||||
switchHref string,
|
||||
switchLabel string,
|
||||
data AuthPageData,
|
||||
) templ.Component {
|
||||
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||
errBlock := ""
|
||||
if data.Error != "" {
|
||||
errBlock = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(data.Error))
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>%s · Satoru</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card auth-card">
|
||||
<p class="eyebrow">Satoru</p>
|
||||
<h1>%s</h1>
|
||||
<p class="muted">%s</p>
|
||||
%s
|
||||
<form class="stack" method="post" action="%s">
|
||||
<label class="stack">
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" value="%s" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button class="button" type="submit">%s</button>
|
||||
</form>
|
||||
<p class="muted"><a href="%s">%s</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
html.EscapeString(title),
|
||||
html.EscapeString(heading),
|
||||
html.EscapeString(description),
|
||||
errBlock,
|
||||
html.EscapeString(action),
|
||||
html.EscapeString(data.Username),
|
||||
html.EscapeString(submitLabel),
|
||||
html.EscapeString(switchHref),
|
||||
html.EscapeString(switchLabel),
|
||||
))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"satoru/internal/store"
|
||||
)
|
||||
|
||||
type RuntimeCheck struct {
|
||||
Name string
|
||||
Installed bool
|
||||
Details string
|
||||
}
|
||||
|
||||
type WorkflowStage struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
Now time.Time
|
||||
User store.User
|
||||
Sites []store.Site
|
||||
RuntimeChecks []RuntimeCheck
|
||||
WorkflowStages []WorkflowStage
|
||||
FlashMessage string
|
||||
}
|
||||
|
||||
func Dashboard(data DashboardData) templ.Component {
|
||||
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||
role := "Operator"
|
||||
if data.User.IsAdmin {
|
||||
role = "Admin"
|
||||
}
|
||||
|
||||
var checks strings.Builder
|
||||
for _, c := range data.RuntimeChecks {
|
||||
status := "missing"
|
||||
if c.Installed {
|
||||
status = "installed"
|
||||
}
|
||||
checks.WriteString(fmt.Sprintf(`<li class="status %s"><strong>%s</strong><span>%s</span></li>`,
|
||||
status,
|
||||
html.EscapeString(c.Name),
|
||||
html.EscapeString(c.Details)))
|
||||
}
|
||||
|
||||
var flows strings.Builder
|
||||
for _, s := range data.WorkflowStages {
|
||||
flows.WriteString(fmt.Sprintf(`<li class="workflow-step"><strong>%s</strong><p>%s</p></li>`,
|
||||
html.EscapeString(s.Title),
|
||||
html.EscapeString(s.Description)))
|
||||
}
|
||||
|
||||
var sites strings.Builder
|
||||
if len(data.Sites) == 0 {
|
||||
sites.WriteString(`<p class="muted">No sites added yet. Add your first Linux SSH site below.</p>`)
|
||||
}
|
||||
for _, site := range data.Sites {
|
||||
last := "Never run"
|
||||
if site.LastRunAt.Valid {
|
||||
last = site.LastRunAt.Time.Local().Format(time.RFC1123)
|
||||
}
|
||||
runStatus := "pending"
|
||||
if site.LastRunStatus.Valid {
|
||||
runStatus = site.LastRunStatus.String
|
||||
}
|
||||
runOutput := "(no output yet)"
|
||||
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
|
||||
runOutput = site.LastRunOutput.String
|
||||
}
|
||||
|
||||
sites.WriteString(fmt.Sprintf(`
|
||||
<article class="site-card">
|
||||
<div class="site-head">
|
||||
<h3>%s@%s</h3>
|
||||
<form method="post" action="/sites/%d/run">
|
||||
<button class="button" type="submit">Run</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted">Path: <code>%s</code></p>
|
||||
<p class="muted">Last run: %s</p>
|
||||
<p class="muted">Status: <span class="pill %s">%s</span></p>
|
||||
<pre class="output">%s</pre>
|
||||
</article>`,
|
||||
html.EscapeString(site.SSHUser),
|
||||
html.EscapeString(site.Host),
|
||||
site.ID,
|
||||
html.EscapeString(site.RemotePath),
|
||||
html.EscapeString(last),
|
||||
html.EscapeString(runStatus),
|
||||
html.EscapeString(runStatus),
|
||||
html.EscapeString(runOutput),
|
||||
))
|
||||
}
|
||||
|
||||
flash := ""
|
||||
if data.FlashMessage != "" {
|
||||
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage)))
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Satoru Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card dashboard-card">
|
||||
<div class="row split">
|
||||
<div>
|
||||
<p class="eyebrow">Satoru</p>
|
||||
<h1>Managed Sites Overview</h1>
|
||||
<p class="muted">Signed in as <strong>%s</strong> (%s).</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="button ghost" href="/account/password">Change password</a>
|
||||
<form method="post" action="/signout"><button class="button ghost" type="submit">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
%s
|
||||
<section>
|
||||
<h2>Host Runtime Status</h2>
|
||||
<ul class="status-list">%s</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Planned Workflow</h2>
|
||||
<ol class="workflow">%s</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add Site</h2>
|
||||
<form class="grid-3" method="post" action="/sites">
|
||||
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
|
||||
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
|
||||
<label class="stack"><span>Path</span><input name="remote_path" placeholder="/var/www" required /></label>
|
||||
<button class="button" type="submit">Add site</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Managed Sites</h2>
|
||||
%s
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
html.EscapeString(data.User.Username),
|
||||
html.EscapeString(role),
|
||||
flash,
|
||||
checks.String(),
|
||||
flows.String(),
|
||||
sites.String(),
|
||||
))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func formatFlash(code string) string {
|
||||
switch code {
|
||||
case "site-added":
|
||||
return "Site added."
|
||||
case "site-ran":
|
||||
return "Run completed."
|
||||
case "site-invalid":
|
||||
return "All site fields are required."
|
||||
case "password-updated":
|
||||
return "Password updated."
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"satoru/internal/store"
|
||||
)
|
||||
|
||||
func Home(now time.Time, user store.User) templ.Component {
|
||||
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||
role := "Operator"
|
||||
if user.IsAdmin {
|
||||
role = "Admin"
|
||||
}
|
||||
|
||||
var body string
|
||||
if user.ID == 0 {
|
||||
body = `<p class="muted">Sign in to continue.</p>
|
||||
<div class="row">
|
||||
<a class="button" href="/signin">Sign in</a>
|
||||
<a class="button ghost" href="/signup">Sign up</a>
|
||||
</div>`
|
||||
} else {
|
||||
body = fmt.Sprintf(`<p class="muted">Signed in as <strong>%s</strong> (%s).</p>
|
||||
<p class="muted">Current server time: %s</p>
|
||||
<form method="post" action="/signout">
|
||||
<button class="button ghost" type="submit">Sign out</button>
|
||||
</form>`, html.EscapeString(user.Username), role, now.Format(time.RFC1123))
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Satoru</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">Satoru</p>
|
||||
<h1>Backup Control Plane</h1>
|
||||
%s
|
||||
</main>
|
||||
</body>
|
||||
</html>`, body))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"satoru/internal/store"
|
||||
)
|
||||
|
||||
type PasswordPageData struct {
|
||||
User store.User
|
||||
Error string
|
||||
}
|
||||
|
||||
func ChangePassword(data PasswordPageData) templ.Component {
|
||||
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||
errBlock := ""
|
||||
if data.Error != "" {
|
||||
errBlock = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(data.Error))
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Change Password · Satoru</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card auth-card">
|
||||
<p class="eyebrow">Satoru</p>
|
||||
<h1>Change Password</h1>
|
||||
<p class="muted">User: <strong>%s</strong></p>
|
||||
%s
|
||||
<form class="stack" method="post" action="/account/password">
|
||||
<label class="stack">
|
||||
<span>Current password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span>New password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span>Confirm new password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required />
|
||||
</label>
|
||||
<button class="button" type="submit">Update password</button>
|
||||
</form>
|
||||
<p class="muted"><a href="/">Back to dashboard</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
html.EscapeString(data.User.Username),
|
||||
errBlock,
|
||||
))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0f172a;
|
||||
--fg: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--card: #111827;
|
||||
--border: #334155;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%);
|
||||
color: var(--fg);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(680px, calc(100vw - 2rem));
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 85%, black);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
width: min(980px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.split {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(480px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: #38bdf8;
|
||||
color: #082f49;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 0.65rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--card) 75%, black);
|
||||
color: var(--fg);
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #ef4444;
|
||||
background: color-mix(in srgb, #ef4444 22%, transparent);
|
||||
border-radius: 10px;
|
||||
color: #fecaca;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
border: 1px solid #22c55e;
|
||||
background: color-mix(in srgb, #22c55e 18%, transparent);
|
||||
border-radius: 10px;
|
||||
color: #bbf7d0;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.status.installed {
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.status.missing {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.workflow {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.workflow-step p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.site-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.site-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
border-color: #22c55e;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.pill.failed {
|
||||
border-color: #ef4444;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.output {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
background: color-mix(in srgb, var(--card) 80%, black);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
Loading…
Reference in New Issue