6 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
d3c174d9b0 Support $PORT env var for MCP agent port allocation
MCP agent sets $PORT for containers with route declarations.
$PORT takes precedence over HTTP_PORT from config, matching the mcdsl
convention used by all other platform services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:07:49 -07:00
0ecea29413 remove $PORT, use config for listen port
MCP's pasta port mapping maps host:randomport to container:routeport.
The app must listen on the route's port (443) inside the container,
configured via HTTP_PORT in kls.conf.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:07:26 -07:00
06d469abaf restore $PORT support, remove explicit ports mapping
The MCP agent assigns its own host port and sets $PORT. The app must
listen on $PORT for mc-proxy to reach it. Explicit Podman port mappings
in the service definition are ignored by the agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:01:29 -07:00
a94b92f7fd fix deployment: use config port, not $PORT
The MCP agent sets $PORT from the route, but the Podman port mapping
expects the container to listen on the config-defined port (3030).
Remove $PORT override to avoid the mismatch. Also restore ports,
network, and user fields in the service definition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:57:33 -07:00
12 changed files with 501 additions and 125 deletions

View File

@@ -9,10 +9,6 @@ kls = "Dockerfile"
[[components]]
name = "kls"
image = "mcr.svc.mcp.metacircular.net:8443/kls:v0.1.0"
network = "mcpnet"
user = "0:0"
restart = "unless-stopped"
ports = ["127.0.0.1:48000:8000"]
volumes = ["/srv/kls:/srv/kls"]
cmd = ["-f", "/srv/kls/kls.conf"]

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 {

69
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,22 +33,59 @@ 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)
var addr string
if port := os.Getenv("PORT"); port != "" {
addr = ":" + port
} else {
addr = net.JoinHostPort(
config.Get("HTTP_ADDR"),
config.GetDefault("HTTP_PORT", "8000"),
)
// 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.
port := os.Getenv("PORT")
if port == "" {
port = config.GetDefault("HTTP_PORT", "8000")
}
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}}