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>
This commit is contained in:
2026-03-28 18:13:44 -07:00
parent d3c174d9b0
commit 9db3883706
11 changed files with 513 additions and 107 deletions

6
go.mod
View File

@@ -1,10 +1,12 @@
module git.wntrmute.dev/kyle/kls module git.wntrmute.dev/kyle/kls
go 1.25.0 go 1.25.7
require ( require (
git.wntrmute.dev/kyle/goutils v1.7.4 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/Masterminds/squirrel v1.5.2
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v4 v4.18.3 github.com/jackc/pgx/v4 v4.18.3
modernc.org/sqlite v1.48.0 modernc.org/sqlite v1.48.0
@@ -26,7 +28,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.20.0 // indirect golang.org/x/crypto v0.20.0 // indirect
golang.org/x/sys v0.42.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/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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 h1:kbvUoxRwAEemz4jL52AUKaOipuCX8F8PGTQHS5V3lRY=
git.wntrmute.dev/kyle/goutils v1.7.4/go.mod h1:1PGn83Ac98KWyI6yfpCVyP1Ji61PX6lFpROxY+IoTJg= 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/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/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/squirrel v1.5.2 h1:UiOEi2ZX4RCSkpiNDQN5kro/XIBpSRk9iTqdIRPzUXE= 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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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-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-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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-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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/subtle"
"database/sql" "database/sql"
"embed" "embed"
"fmt" "fmt"
@@ -11,26 +10,44 @@ import (
"net/http" "net/http"
"strings" "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" "git.wntrmute.dev/kyle/kls/links"
"github.com/go-chi/chi/v5"
) )
type server struct { type server struct {
db *sql.DB db *sql.DB
auth *auth.Authenticator
csrf *csrf.Protect
} }
//go:embed templates/*.tpl //go:embed templates/*.html
var templateFiles embed.FS var templateFiles embed.FS
var templates = template.Must(template.ParseFS(templateFiles, "templates/*.tpl")) //go:embed static
var staticFiles embed.FS
var templates = template.Must(template.ParseFS(templateFiles, "templates/*.html"))
type page struct { type page struct {
Username string
Short string Short string
URLs []*links.URL URLs []*links.URL
} }
func (srv *server) servePage(w http.ResponseWriter, p page, tpl string) { func (srv *server) servePage(w http.ResponseWriter, p page, tpl string, funcs ...template.FuncMap) {
err := templates.ExecuteTemplate(w, tpl, p) t := templates
if len(funcs) > 0 {
t = template.Must(t.Clone())
for _, fm := range funcs {
t.Funcs(fm)
}
// Re-parse to pick up the funcs.
t = template.Must(template.New("").Funcs(funcs[0]).ParseFS(templateFiles, "templates/*.html"))
}
err := t.ExecuteTemplate(w, tpl, p)
if err != nil { if err != nil {
log.Printf("error executing template: %s", err) log.Printf("error executing template: %s", err)
http.Error(w, fmt.Sprintf("template execution failed: %s", err.Error()), http.Error(w, fmt.Sprintf("template execution failed: %s", err.Error()),
@@ -39,7 +56,42 @@ func (srv *server) servePage(w http.ResponseWriter, p page, tpl string) {
} }
} }
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 {
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) { func (srv *server) postURL(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -67,11 +119,14 @@ func (srv *server) postURL(w http.ResponseWriter, r *http.Request) {
return 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) { 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) u, err := links.RetrieveURL(context.Background(), srv.db, short)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@@ -82,47 +137,17 @@ func (srv *server) redirect(w http.ResponseWriter, r *http.Request) {
} }
func (srv *server) listAll(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) allPosts, err := links.FetchAll(context.Background(), srv.db)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
p := page{ web.RenderTemplate(w, templateFiles, "list.html", page{
Username: info.Username,
URLs: allPosts, URLs: allPosts,
} }, srv.csrf.TemplateFunc(w))
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")
} }
func boolean(s string) bool { func boolean(s string) bool {

55
main.go
View File

@@ -2,17 +2,27 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"flag" "flag"
"fmt"
"log" "log"
"log/slog"
"net" "net"
"net/http" "net/http"
"os" "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/config"
"git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/kls/links" "git.wntrmute.dev/kyle/kls/links"
) )
const cookieName = "kls_session"
var defaultConfigFile = config.DefaultConfigPath("kls", "kls.conf") var defaultConfigFile = config.DefaultConfigPath("kls", "kls.conf")
func main() { func main() {
@@ -23,12 +33,51 @@ func main() {
err := config.LoadFile(*cfgFile) err := config.LoadFile(*cfgFile)
die.If(err) die.If(err)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx := context.Background() ctx := context.Background()
db, err := links.Connect(ctx) db, err := links.Connect(ctx)
die.If(err) die.If(err)
srv := &server{db: db} // MCIAS authenticator.
http.Handle("/", srv) 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. // $PORT is set by the MCP agent for containers with route declarations.
// It takes precedence over config, matching the mcdsl convention. // It takes precedence over config, matching the mcdsl convention.
@@ -38,5 +87,5 @@ func main() {
} }
addr := net.JoinHostPort(config.Get("HTTP_ADDR"), port) addr := net.JoinHostPort(config.Get("HTTP_ADDR"), port)
log.Print("listening on ", addr) 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}}