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:
6
go.mod
6
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||||
|
|||||||
109
handler.go
109
handler.go
@@ -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
55
main.go
@@ -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
270
static/style.css
Normal 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
27
templates/index.html
Normal 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}}
|
||||||
@@ -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
26
templates/layout.html
Normal 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
27
templates/list.html
Normal 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}}
|
||||||
@@ -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> → <a href="{{.URL}}">{{.NURL}}</a></li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
30
templates/login.html
Normal file
30
templates/login.html
Normal 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}}
|
||||||
Reference in New Issue
Block a user