From 9db3883706318f525ff19cb4dc3ffe1a9e66b0fa Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 28 Mar 2026 18:13:44 -0700 Subject: [PATCH] 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) --- go.mod | 6 +- go.sum | 8 +- handler.go | 117 +++++++++++------- main.go | 55 ++++++++- static/style.css | 270 ++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 27 +++++ templates/index.tpl | 29 ----- templates/layout.html | 26 ++++ templates/list.html | 27 +++++ templates/list.tpl | 25 ---- templates/login.html | 30 +++++ 11 files changed, 513 insertions(+), 107 deletions(-) create mode 100644 static/style.css create mode 100644 templates/index.html delete mode 100644 templates/index.tpl create mode 100644 templates/layout.html create mode 100644 templates/list.html delete mode 100644 templates/list.tpl create mode 100644 templates/login.html diff --git a/go.mod b/go.mod index 644fec4..d244ba1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0467d51..a64ebd2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handler.go b/handler.go index c05eab8..a2b5a9c 100644 --- a/handler.go +++ b/handler.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/subtle" "database/sql" "embed" "fmt" @@ -11,26 +10,44 @@ import ( "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 + 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 + +var templates = template.Must(template.ParseFS(templateFiles, "templates/*.html")) type page struct { - Short string - URLs []*links.URL + 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) servePage(w http.ResponseWriter, p page, tpl string, funcs ...template.FuncMap) { + 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 { log.Printf("error executing template: %s", err) 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) { + info := auth.TokenInfoFromContext(r.Context()) + err := r.ParseForm() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -67,11 +119,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 +137,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{ - 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") + web.RenderTemplate(w, templateFiles, "list.html", page{ + Username: info.Username, + URLs: allPosts, + }, srv.csrf.TemplateFunc(w)) } func boolean(s string) bool { diff --git a/main.go b/main.go index 6fb4918..995d1c4 100644 --- a/main.go +++ b/main.go @@ -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)) } diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6b54ab8 --- /dev/null +++ b/static/style.css @@ -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; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..932c9b5 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,27 @@ +{{define "title"}} — Shorten{{end}} +{{define "content"}} +

Shorten a URL

+{{if (ne .Short "")}} +
+ Short code: {{.Short}} +
+{{end}} +
+
+ {{csrfField}} +
+ + +
+
+ +
+
+ + View all +
+
+
+{{end}} diff --git a/templates/index.tpl b/templates/index.tpl deleted file mode 100644 index 35e44fe..0000000 --- a/templates/index.tpl +++ /dev/null @@ -1,29 +0,0 @@ - - - - kls - - - - - - - - - - -

KLS

- {{ if (ne .Short "") }} -

Short code: {{.Short}}

- {{ end }} -
-
- - -
- -
- - diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..761330e --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,26 @@ +{{define "layout"}} + + + + + kls{{block "title" .}}{{end}} + + + + +
+ {{template "content" .}} +
+ +{{end}} diff --git a/templates/list.html b/templates/list.html new file mode 100644 index 0000000..1570eba --- /dev/null +++ b/templates/list.html @@ -0,0 +1,27 @@ +{{define "title"}} — All Links{{end}} +{{define "content"}} +

All Links

+

Shorten a URL

+{{if .URLs}} + + + + + + + + + + {{range .URLs}} + + + + + + {{end}} + +
ShortURLCreated
{{.Short}}{{.NURL}}{{.Timestamp}}
+{{else}} +

No links yet.

+{{end}} +{{end}} diff --git a/templates/list.tpl b/templates/list.tpl deleted file mode 100644 index dd103f4..0000000 --- a/templates/list.tpl +++ /dev/null @@ -1,25 +0,0 @@ - - - - kls - - - - - - - - - - -

KLS

-

Home

- - - diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ab7a260 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,30 @@ +{{define "title"}} — Login{{end}} +{{define "content"}} +
+
kls
+
Link Shortener
+
+
+ {{if (ne .Short "")}} +
{{.Short}}
+ {{end}} +
+ {{csrfField}} +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+{{end}}