feat(webui): bootstrap session handling and login process
This commit is contained in:
parent
63e2b1b09d
commit
6e069484cb
18 changed files with 447 additions and 1 deletions
|
@ -47,6 +47,38 @@ func (db *DB) InitAdminAccount() error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) LoadAccountById(id int) (*model.Account, error) {
|
||||||
|
account := model.Account{
|
||||||
|
Id: id,
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
created int64
|
||||||
|
lastLogin int64
|
||||||
|
)
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT username, salt, password_hash, is_admin, created, last_login, settings
|
||||||
|
FROM accounts
|
||||||
|
WHERE id = ?;`,
|
||||||
|
id,
|
||||||
|
).Scan(&account.Username,
|
||||||
|
&account.Salt,
|
||||||
|
&account.PasswordHash,
|
||||||
|
&account.IsAdmin,
|
||||||
|
&created,
|
||||||
|
&lastLogin,
|
||||||
|
&account.Settings,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to load account by id %d: %w", id, err)
|
||||||
|
}
|
||||||
|
account.Created = time.Unix(created, 0)
|
||||||
|
account.LastLogin = time.Unix(lastLogin, 0)
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
|
func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
|
||||||
account := model.Account{
|
account := model.Account{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
@ -72,7 +104,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to load account by username %s: %w", username, err)
|
||||||
}
|
}
|
||||||
account.Created = time.Unix(created, 0)
|
account.Created = time.Unix(created, 0)
|
||||||
account.LastLogin = time.Unix(lastLogin, 0)
|
account.LastLogin = time.Unix(lastLogin, 0)
|
||||||
|
|
69
pkg/database/sessions.go
Normal file
69
pkg/database/sessions.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.adyxax.org/adyxax/tfstated/pkg/model"
|
||||||
|
"go.n16f.net/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) CreateSession(account *model.Account) (string, error) {
|
||||||
|
var sessionId uuid.UUID
|
||||||
|
if err := sessionId.Generate(uuid.V4); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate session id: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO sessions(id, account_id, data)
|
||||||
|
VALUES (?, ?, ?);`,
|
||||||
|
sessionId.String(),
|
||||||
|
account.Id,
|
||||||
|
"",
|
||||||
|
); err != nil {
|
||||||
|
return "", fmt.Errorf("failed insert new session in database: %w", err)
|
||||||
|
}
|
||||||
|
return sessionId.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) LoadSessionById(id string) (*model.Session, error) {
|
||||||
|
session := model.Session{
|
||||||
|
Id: id,
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
created int64
|
||||||
|
updated int64
|
||||||
|
)
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT account_id,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
data
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = ?;`,
|
||||||
|
id,
|
||||||
|
).Scan(&session.AccountId,
|
||||||
|
&created,
|
||||||
|
&updated,
|
||||||
|
&session.Data,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to load session by id %s: %w", id, err)
|
||||||
|
}
|
||||||
|
session.Created = time.Unix(created, 0)
|
||||||
|
session.Updated = time.Unix(updated, 0)
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) TouchSession(sessionId string) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := db.Exec(`UPDATE sessions SET updated = ? WHERE id = ?`, now.Unix(), sessionId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to touch updated for session %s: %w", sessionId, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -14,6 +14,15 @@ CREATE TABLE accounts (
|
||||||
) STRICT;
|
) STRICT;
|
||||||
CREATE UNIQUE INDEX accounts_username on accounts(username);
|
CREATE UNIQUE INDEX accounts_username on accounts(username);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
updated INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
CREATE TABLE states (
|
CREATE TABLE states (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
|
|
20
pkg/model/session.go
Normal file
20
pkg/model/session.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionContextKey struct{}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Id string
|
||||||
|
AccountId int
|
||||||
|
Created time.Time
|
||||||
|
Updated time.Time
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) IsExpired() bool {
|
||||||
|
// TODO
|
||||||
|
return false
|
||||||
|
}
|
10
pkg/webui/cache.go
Normal file
10
pkg/webui/cache.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
21
pkg/webui/error.go
Normal file
21
pkg/webui/error.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
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 {
|
||||||
|
Err error
|
||||||
|
Status int
|
||||||
|
StatusText string
|
||||||
|
}
|
||||||
|
render(w, errorTemplates, status, &ErrorData{
|
||||||
|
Err: err,
|
||||||
|
Status: status,
|
||||||
|
StatusText: http.StatusText(status),
|
||||||
|
})
|
||||||
|
}
|
20
pkg/webui/html/base.html
Normal file
20
pkg/webui/html/base.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" href="/static/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<title>tfstated</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
{{ template "main" . }}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
6
pkg/webui/html/error.html
Normal file
6
pkg/webui/html/error.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{{ define "main" }}
|
||||||
|
<article>
|
||||||
|
<h1>{{ .Status }} - {{ .StatusText }}</h1>
|
||||||
|
<p>{{ .Err }}</p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
3
pkg/webui/html/index.html
Normal file
3
pkg/webui/html/index.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{{ define "main" }}
|
||||||
|
<h1>TODO</h1>
|
||||||
|
{{ end }}
|
29
pkg/webui/html/login.html
Normal file
29
pkg/webui/html/login.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{{ define "main" }}
|
||||||
|
{{ if .Forbidden }}
|
||||||
|
<article>
|
||||||
|
<p class="error-message">Invalid username or password</p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/login" method="post">
|
||||||
|
<fieldset>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
name="username"
|
||||||
|
value="{{ .Username }}"
|
||||||
|
{{ if .Forbidden }}aria-invalid="true"{{ end }}
|
||||||
|
required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
name="password"
|
||||||
|
{{ if .Forbidden }}aria-invalid="true"{{ end }}
|
||||||
|
required>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit" value="login">Login</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
16
pkg/webui/index.go
Normal file
16
pkg/webui/index.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var indexTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/index.html"))
|
||||||
|
|
||||||
|
func handleIndexGET() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store, no-cache")
|
||||||
|
|
||||||
|
render(w, indexTemplates, http.StatusOK, nil)
|
||||||
|
})
|
||||||
|
}
|
115
pkg/webui/login.go
Normal file
115
pkg/webui/login.go
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"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 {
|
||||||
|
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, "/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, loginTemplate, http.StatusOK, loginPage{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
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) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) 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, "/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)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
22
pkg/webui/render.go
Normal file
22
pkg/webui/render.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,5 +10,11 @@ func addRoutes(
|
||||||
mux *http.ServeMux,
|
mux *http.ServeMux,
|
||||||
db *database.DB,
|
db *database.DB,
|
||||||
) {
|
) {
|
||||||
|
session := sessionsMiddleware(db)
|
||||||
|
requireLogin := loginMiddleware(db)
|
||||||
mux.Handle("GET /healthz", handleHealthz())
|
mux.Handle("GET /healthz", handleHealthz())
|
||||||
|
mux.Handle("GET /login", session(handleLoginGET()))
|
||||||
|
mux.Handle("POST /login", session(handleLoginPOST(db)))
|
||||||
|
mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
|
||||||
|
mux.Handle("GET /", session(requireLogin(handleIndexGET())))
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"embed"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -10,6 +11,12 @@ import (
|
||||||
"git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger"
|
"git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed html/*
|
||||||
|
var htmlFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
func Run(
|
func Run(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *database.DB,
|
db *database.DB,
|
||||||
|
|
55
pkg/webui/sessions.go
Normal file
55
pkg/webui/sessions.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
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 {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Quoted: false,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 0, // remove invalid cookie
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
session, err := db.LoadSessionById(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
errorResponse(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
3
pkg/webui/static/favicon.svg
Normal file
3
pkg/webui/static/favicon.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 948 B |
3
pkg/webui/static/main.css
Normal file
3
pkg/webui/static/main.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.error-message {
|
||||||
|
color:red;
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue