2 Commits

Author SHA1 Message Date
131924520d Remove unused template init (fix startup crash)
The global template.Must parse at init time was left over from the
basic auth version. web.RenderTemplate handles parsing per-request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:15:46 -07:00
9db3883706 Switch from HTTP basic auth to MCIAS
Replace HTTP basic auth with MCIAS session cookies. Adds mcdsl auth,
csrf, and web packages. Chi router for route-level auth middleware.
Short code redirects (GET /{short}) remain public; all management
pages require MCIAS login. Nord dark theme, layout+page template
pattern matching other platform services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:13:44 -07:00
11 changed files with 495 additions and 113 deletions

6
go.mod
View File

@@ -1,10 +1,12 @@
module git.wntrmute.dev/kyle/kls
go 1.25.0
go 1.25.7
require (
git.wntrmute.dev/kyle/goutils v1.7.4
git.wntrmute.dev/mc/mcdsl v1.2.0
github.com/Masterminds/squirrel v1.5.2
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v4 v4.18.3
modernc.org/sqlite v1.48.0
@@ -26,7 +28,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.32.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

8
go.sum
View File

@@ -1,5 +1,7 @@
git.wntrmute.dev/kyle/goutils v1.7.4 h1:kbvUoxRwAEemz4jL52AUKaOipuCX8F8PGTQHS5V3lRY=
git.wntrmute.dev/kyle/goutils v1.7.4/go.mod h1:1PGn83Ac98KWyI6yfpCVyP1Ji61PX6lFpROxY+IoTJg=
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/squirrel v1.5.2 h1:UiOEi2ZX4RCSkpiNDQN5kro/XIBpSRk9iTqdIRPzUXE=
@@ -14,6 +16,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -183,8 +187,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=

View File

@@ -2,44 +2,72 @@ package main
import (
"context"
"crypto/subtle"
"database/sql"
"embed"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"git.wntrmute.dev/kyle/goutils/config"
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
"git.wntrmute.dev/mc/mcdsl/web"
"git.wntrmute.dev/kyle/kls/links"
"github.com/go-chi/chi/v5"
)
type server struct {
db *sql.DB
auth *auth.Authenticator
csrf *csrf.Protect
}
//go:embed templates/*.tpl
//go:embed templates/*.html
var templateFiles embed.FS
var templates = template.Must(template.ParseFS(templateFiles, "templates/*.tpl"))
//go:embed static
var staticFiles embed.FS
type page struct {
Username string
Short string
URLs []*links.URL
}
func (srv *server) servePage(w http.ResponseWriter, p page, tpl string) {
err := templates.ExecuteTemplate(w, tpl, p)
func (srv *server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, templateFiles, "login.html", page{}, srv.csrf.TemplateFunc(w))
}
func (srv *server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
totpCode := r.FormValue("totp_code")
token, _, err := srv.auth.Login(username, password, totpCode)
if err != nil {
log.Printf("error executing template: %s", err)
http.Error(w, fmt.Sprintf("template execution failed: %s", err.Error()),
http.StatusInternalServerError)
web.RenderTemplate(w, templateFiles, "login.html", page{Short: "Invalid credentials"}, srv.csrf.TemplateFunc(w))
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, "/", http.StatusFound)
}
func (srv *server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName)
if token != "" {
_ = srv.auth.Logout(token)
}
web.ClearSessionCookie(w, cookieName)
http.Redirect(w, r, "/login", http.StatusFound)
}
func (srv *server) handleIndex(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
web.RenderTemplate(w, templateFiles, "index.html", page{Username: info.Username}, srv.csrf.TemplateFunc(w))
}
func (srv *server) postURL(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -67,11 +95,14 @@ func (srv *server) postURL(w http.ResponseWriter, r *http.Request) {
return
}
srv.servePage(w, page{Short: short}, "index.tpl")
web.RenderTemplate(w, templateFiles, "index.html", page{Username: info.Username, Short: short}, srv.csrf.TemplateFunc(w))
}
func (srv *server) redirect(w http.ResponseWriter, r *http.Request) {
short := strings.TrimPrefix(r.URL.Path, "/")
short := chi.URLParam(r, "short")
if short == "" {
short = strings.TrimPrefix(r.URL.Path, "/")
}
u, err := links.RetrieveURL(context.Background(), srv.db, short)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -82,47 +113,17 @@ func (srv *server) redirect(w http.ResponseWriter, r *http.Request) {
}
func (srv *server) listAll(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
allPosts, err := links.FetchAll(context.Background(), srv.db)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p := page{
web.RenderTemplate(w, templateFiles, "list.html", page{
Username: info.Username,
URLs: allPosts,
}
srv.servePage(w, p, "list.tpl")
}
func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && links.ValidShortCode.MatchString(r.URL.Path) {
srv.redirect(w, r)
return
}
user, pass, ok := r.BasicAuth()
username := config.Get("HTTP_USER")
password := config.Get("HTTP_PASS")
if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="kls"`)
w.WriteHeader(401)
w.Write([]byte("Unauthorised.\n"))
return
}
if r.Method == http.MethodPost {
srv.postURL(w, r)
return
}
if r.URL.Path == "/list" {
srv.listAll(w, r)
return
}
srv.servePage(w, page{}, "index.tpl")
}, srv.csrf.TemplateFunc(w))
}
func boolean(s string) bool {

55
main.go
View File

@@ -2,17 +2,27 @@ package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
"git.wntrmute.dev/mc/mcdsl/web"
"git.wntrmute.dev/kyle/goutils/config"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/kls/links"
)
const cookieName = "kls_session"
var defaultConfigFile = config.DefaultConfigPath("kls", "kls.conf")
func main() {
@@ -23,12 +33,51 @@ func main() {
err := config.LoadFile(*cfgFile)
die.If(err)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx := context.Background()
db, err := links.Connect(ctx)
die.If(err)
srv := &server{db: db}
http.Handle("/", srv)
// MCIAS authenticator.
authenticator, err := auth.New(auth.Config{
ServerURL: config.Get("MCIAS_SERVER_URL"),
CACert: config.Get("MCIAS_CA_CERT"),
ServiceName: config.GetDefault("MCIAS_SERVICE_NAME", "kls"),
}, logger)
die.If(err)
// CSRF protection.
csrfSecret := make([]byte, 32)
if _, err := rand.Read(csrfSecret); err != nil {
die.If(fmt.Errorf("generate CSRF secret: %w", err))
}
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
srv := &server{db: db, auth: authenticator, csrf: csrfProtect}
r := chi.NewRouter()
// Public: short code redirects (no auth).
r.Get("/{short:[a-zA-Z0-9]+}", srv.redirect)
// Public: login page.
r.Get("/login", srv.handleLoginPage)
r.Post("/login", csrfProtect.Middleware(http.HandlerFunc(srv.handleLogin)).ServeHTTP)
// Static files.
r.Get("/static/*", http.FileServer(http.FS(staticFiles)).ServeHTTP)
// Authenticated routes.
r.Group(func(r chi.Router) {
r.Use(web.RequireAuth(authenticator, cookieName, "/login"))
r.Use(csrfProtect.Middleware)
r.Get("/", srv.handleIndex)
r.Post("/", srv.postURL)
r.Get("/list", srv.listAll)
r.Post("/logout", srv.handleLogout)
})
// $PORT is set by the MCP agent for containers with route declarations.
// It takes precedence over config, matching the mcdsl convention.
@@ -38,5 +87,5 @@ func main() {
}
addr := net.JoinHostPort(config.Get("HTTP_ADDR"), port)
log.Print("listening on ", addr)
log.Fatal(http.ListenAndServe(addr, nil))
log.Fatal(http.ListenAndServe(addr, r))
}

270
static/style.css Normal file
View File

@@ -0,0 +1,270 @@
/* mcat — Nord dark theme */
/* ===========================
Colour tokens (Nord palette)
=========================== */
:root {
--n0: #2E3440;
--n1: #3B4252;
--n2: #434C5E;
--n3: #4C566A;
--s0: #D8DEE9;
--s1: #E5E9F0;
--s2: #ECEFF4;
--f0: #8FBCBB;
--f1: #88C0D0;
--f2: #81A1C1;
--f3: #5E81AC;
--red: #BF616A;
--green: #A3BE8C;
}
/* ===========================
Reset
=========================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; }
/* ===========================
Base
=========================== */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--n0);
color: var(--s0);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--f1); text-decoration: none; }
a:hover { color: var(--f0); text-decoration: underline; }
p { margin-bottom: 0.875rem; }
h2 { font-size: 1.375rem; font-weight: 600; color: var(--s2); margin-bottom: 0.25rem; }
code {
font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
font-size: 0.8125rem;
color: var(--f0);
background: var(--n2);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
/* ===========================
Top navigation
=========================== */
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 52px;
background: var(--n1);
border-bottom: 1px solid var(--n3);
position: sticky;
top: 0;
z-index: 100;
}
.topnav-brand {
font-size: 1rem;
font-weight: 700;
color: var(--s2);
text-decoration: none;
letter-spacing: 0.04em;
}
.topnav-brand:hover { color: var(--f1); text-decoration: none; }
.topnav-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.topnav-user {
font-size: 0.875rem;
color: var(--s1);
}
/* ===========================
Page containers
=========================== */
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.auth-container {
max-width: 420px;
margin: 5rem auto 2rem;
padding: 0 1rem;
}
/* ===========================
Auth pages
=========================== */
.auth-header {
text-align: center;
margin-bottom: 1.75rem;
}
.auth-header .brand {
font-size: 1.5rem;
font-weight: 700;
color: var(--s2);
letter-spacing: 0.04em;
}
.auth-header .tagline {
font-size: 0.6875rem;
color: var(--f2);
text-transform: uppercase;
letter-spacing: 0.12em;
margin-top: 0.25rem;
}
/* ===========================
Cards
=========================== */
.card {
background: var(--n1);
border: 1px solid var(--n3);
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.card:last-child { margin-bottom: 0; }
.card-title {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--f2);
margin-bottom: 1rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid var(--n2);
}
.card p:last-child { margin-bottom: 0; }
/* ===========================
Alerts
=========================== */
.error {
background: rgba(191, 97, 106, 0.12);
color: #e07c82;
border: 1px solid rgba(191, 97, 106, 0.3);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.success {
background: rgba(163, 190, 140, 0.1);
border: 1px solid rgba(163, 190, 140, 0.3);
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--green);
}
/* ===========================
Buttons
=========================== */
button, .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
border: 1px solid var(--f3);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, color 0.12s;
line-height: 1.4;
background: var(--f3);
color: var(--s2);
}
button:hover, .btn:hover {
background: var(--f2);
border-color: var(--f2);
text-decoration: none;
color: var(--s2);
}
.btn-ghost {
background: transparent;
color: var(--s0);
border-color: var(--n3);
}
.btn-ghost:hover {
background: var(--n2);
color: var(--s1);
border-color: var(--n3);
text-decoration: none;
}
/* ===========================
Forms
=========================== */
.form-group { margin-bottom: 1rem; }
.form-group label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: var(--s0);
margin-bottom: 0.375rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.form-group input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--n0);
border: 1px solid var(--n3);
border-radius: 4px;
color: var(--s1);
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
-webkit-appearance: none;
appearance: none;
}
.form-group input:focus {
outline: none;
border-color: var(--f3);
box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2);
}
.form-group input::placeholder { color: var(--n3); }
.form-actions { margin-top: 0.25rem; }
/* ===========================
Session info
=========================== */
.session-info {
font-size: 0.875rem;
color: var(--s0);
}
.session-info dt {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--f2);
margin-bottom: 0.25rem;
}
.session-info dd {
margin-bottom: 0.75rem;
margin-left: 0;
}
.session-info dd:last-child { margin-bottom: 0; }
.role-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
background: rgba(94, 129, 172, 0.2);
color: var(--f1);
border: 1px solid rgba(94, 129, 172, 0.35);
margin-right: 0.25rem;
}

27
templates/index.html Normal file
View File

@@ -0,0 +1,27 @@
{{define "title"}} — Shorten{{end}}
{{define "content"}}
<h2>Shorten a URL</h2>
{{if (ne .Short "")}}
<div class="success">
Short code: <a href="/{{.Short}}">{{.Short}}</a>
</div>
{{end}}
<div class="card">
<form method="POST" action="/">
{{csrfField}}
<div class="form-group">
<label for="value">URL</label>
<input type="text" id="value" name="value" placeholder="https://..." required>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="strip"> Strip query string
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn">Shorten</button>
<a href="/list" class="btn-ghost btn">View all</a>
</div>
</form>
</div>
{{end}}

View File

@@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>kls</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="kls" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://kls.ai6ua.net/" />
<style>
* { font-size: large; }
</style>
</head>
<body>
<h2>KLS</h2>
{{ if (ne .Short "") }}
<p>Short code: {{.Short}}</p>
{{ end }}
<form action="/" method="POST">
<input type="text" id="value" name="value" /><br />
<label for="strip">Strip query string</label>
<input type="checkbox" id="strip" name="strip">
<br />
<input type="submit" value="Submit">
</form>
</body>
</html>

26
templates/layout.html Normal file
View File

@@ -0,0 +1,26 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>kls{{block "title" .}}{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-brand">kls</a>
{{if .Username}}
<div class="topnav-right">
<span class="topnav-user">{{.Username}}</span>
<form method="POST" action="/logout" style="margin:0">
{{csrfField}}
<button type="submit" class="btn-ghost btn">Logout</button>
</form>
</div>
{{end}}
</nav>
<div class="page-container">
{{template "content" .}}
</div>
</body>
</html>{{end}}

27
templates/list.html Normal file
View File

@@ -0,0 +1,27 @@
{{define "title"}} — All Links{{end}}
{{define "content"}}
<h2>All Links</h2>
<p><a href="/">Shorten a URL</a></p>
{{if .URLs}}
<table>
<thead>
<tr>
<th>Short</th>
<th>URL</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .URLs}}
<tr>
<td><a href="/{{.Short}}">{{.Short}}</a></td>
<td><a href="{{.URL}}">{{.NURL}}</a></td>
<td>{{.Timestamp}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No links yet.</p>
{{end}}
{{end}}

View File

@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>kls</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="kls" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://kls.ai6ua.net/" />
<style>
* { font-size: large; }
</style>
</head>
<body>
<h2>KLS</h2>
<p><a href="/">Home</a></p>
<ul>
{{range .URLs}}
<li>({{.Timestamp}}) <a href="/{{.Short}}">{{.Short}}</a> &rarr; <a href="{{.URL}}">{{.NURL}}</a></li>
{{end}}
</ul>
</body>
</html>

30
templates/login.html Normal file
View File

@@ -0,0 +1,30 @@
{{define "title"}} — Login{{end}}
{{define "content"}}
<div class="auth-header">
<div class="brand">kls</div>
<div class="tagline">Link Shortener</div>
</div>
<div class="card">
{{if (ne .Short "")}}
<div class="error">{{.Short}}</div>
{{end}}
<form method="POST" action="/login">
{{csrfField}}
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="totp_code">TOTP Code (optional)</label>
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" autocomplete="one-time-code">
</div>
<div class="form-actions">
<button type="submit" class="btn">Login</button>
</div>
</form>
</div>
{{end}}