Improve page templates and custom template loading

This commit is contained in:
Melon 2024-05-16 03:06:10 +01:00
parent e06c23eec4
commit ba56a628d0
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
22 changed files with 135 additions and 117 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
*.local
.idea/
.data/
node_modules/
pages/style-minify.css

2
go.mod
View File

@ -27,7 +27,7 @@ require (
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/klauspost/compress v1.15.11 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect

4
go.sum
View File

@ -75,8 +75,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.11 h1:Lcadnb3RKGin4FYM/orgq0qde+nc15E5Cbqg4B9Sx9c=
github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=

3
pages/header.go.html Normal file
View File

@ -0,0 +1,3 @@
<header>
<h1>{{.ServiceName}}</h1>
</header>

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<div>Not logged in</div>
<div>

View File

@ -2,14 +2,13 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<div>Logged in as: {{.DisplayName}} ({{.Subject}})</div>
<div>Logged in as: {{.Auth.UserInfo.name}} ({{.Auth.Subject}})</div>
<div>
<form method="GET" action="/manage/apps">
<button type="submit">Manage Applications</button>

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<div>Log in as: <span>{{.LoginName}}</span></div>
<div>

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="{{.Redirect}}"/>

View File

@ -2,7 +2,8 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
<script>
window.addEventListener("load", function () {
selectText("app-secret");
@ -29,9 +30,7 @@
</script>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<form method="GET" action="/">
<button type="submit">Home</button>

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<form method="GET" action="/">
<button type="submit">Home</button>

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
{{template "header.go.html" .}}
<main>
<form method="POST" action="/authorize">
<div>The application {{.AppName}} wants to access your account ({{.DisplayName}}). It requests the following permissions:</div>

View File

@ -1,34 +1,36 @@
package pages
import (
"bytes"
"embed"
_ "embed"
"errors"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/utils"
"github.com/1f349/overlapfs"
"html/template"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
)
var (
//go:embed *.go.html
//go:embed *.go.html assets/*.css
wwwPages embed.FS
wwwTemplates *template.Template
loadOnce sync.Once
loadOnce utils.Once[error]
cssAssetMap = make(map[string][]byte)
)
func LoadPages(wd string) (err error) {
loadOnce.Do(func() {
func LoadPages(wd string) error {
return loadOnce.Do(func() (err error) {
var o fs.FS = wwwPages
if wd != "" {
wwwDir := filepath.Join(wd, "www")
err = os.Mkdir(wwwDir, os.ModePerm)
if err != nil && !errors.Is(err, os.ErrExist) {
return
return err
}
wdFs := os.DirFS(wwwDir)
o = overlapfs.OverlapFS{A: wwwPages, B: wdFs}
@ -36,9 +38,20 @@ func LoadPages(wd string) (err error) {
wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{
"emailHide": EmailHide,
}).ParseFS(o, "*.go.html")
})
glob, err := fs.Glob(o, "assets/*")
if err != nil {
return err
}
for _, i := range glob {
cssAssetMap[i], err = fs.ReadFile(o, i)
if err != nil {
return err
}
}
return nil
})
}
func RenderPageTemplate(wr io.Writer, name string, data any) {
err := wwwTemplates.ExecuteTemplate(wr, name+".go.html", data)
@ -47,6 +60,14 @@ func RenderPageTemplate(wr io.Writer, name string, data any) {
}
}
func RenderCss(name string) io.ReadSeeker {
b, ok := cssAssetMap[name]
if !ok {
return nil
}
return bytes.NewReader(b)
}
func EmailHide(a string) string {
b := []byte(a)
for i := range b {

View File

@ -2,8 +2,6 @@ package server
import (
"github.com/1f349/lavender/database"
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
@ -13,18 +11,18 @@ import (
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
type UserAuth struct {
ID string
Subject string
DisplayName string
UserInfo UserInfoFields
}
func (u UserAuth) IsGuest() bool { return u.ID == "" }
func (u UserAuth) IsGuest() bool { return u.Subject == "" }
func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.ID)
roles, err = tx.GetUserRoles(auth.Subject)
return
}) {
return
@ -50,30 +48,24 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
auth, err := h.internalAuthenticationHandler(req)
authUser, err := h.internalAuthenticationHandler(req)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
if auth.IsGuest() {
// if this fails internally it just sees the user as logged out
h.readLoginDataCookie(req, &auth)
}
next(rw, req, params, auth)
next(rw, req, params, authUser)
}
}
func (h *HttpServer) internalAuthenticationHandler(req *http.Request) (UserAuth, error) {
if loginCookie, err := req.Cookie("lavender-login-data"); err == nil {
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signingKey, loginCookie.Value)
var u UserAuth
err := h.readLoginDataCookie(req, &u)
if err != nil {
return UserAuth{}, err
}
return UserAuth{ID: b.Subject}, nil
}
// not logged in
return UserAuth{}, nil
}
return u, nil
}
func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
// find start of query parameters in target path

View File

@ -30,7 +30,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, _ *http.Request, _ httprouter.
var isAdmin bool
h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err := tx.GetUserRoles(auth.ID)
roles, err := tx.GetUserRoles(auth.Subject)
isAdmin = HasRole(roles, "lavender:admin")
return err
})
@ -38,8 +38,6 @@ func (h *HttpServer) Home(rw http.ResponseWriter, _ *http.Request, _ httprouter.
pages.RenderPageTemplate(rw, "index", map[string]any{
"ServiceName": h.conf.ServiceName,
"Auth": auth,
"Subject": auth.ID,
"DisplayName": auth.DisplayName,
"Nonce": lNonce,
"IsAdmin": isAdmin,
})

View File

@ -2,17 +2,14 @@ package server
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/pages"
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/1f349/mjwt/claims"
"github.com/golang-jwt/jwt/v4"
@ -112,7 +109,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
}
sessionData, err := h.fetchUserInfo(flowState.sso, token)
if sessionData.ID == "" {
if err != nil || sessionData.Subject == "" {
http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError)
return
}
@ -122,15 +119,15 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
if err != nil {
return err
}
_, err = tx.GetUser(sessionData.ID)
_, err = tx.GetUser(sessionData.Subject)
if errors.Is(err, sql.ErrNoRows) {
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
return tx.InsertUser(sessionData.ID, uEmail, uEmailVerified, "", string(jBytes), true)
return tx.InsertUser(sessionData.Subject, uEmail, uEmailVerified, "", string(jBytes), true)
}
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
return tx.UpdateUserInfo(sessionData.ID, uEmail, uEmailVerified, string(jBytes))
return tx.UpdateUserInfo(sessionData.Subject, uEmail, uEmailVerified, string(jBytes))
}) {
return
}
@ -139,7 +136,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
auth = sessionData
if h.DbTx(rw, func(tx *database.Tx) error {
return tx.UpdateUserToken(auth.ID, token.AccessToken, token.RefreshToken, token.Expiry)
return tx.UpdateUserToken(auth.Subject, token.AccessToken, token.RefreshToken, token.Expiry)
}) {
return
}
@ -156,9 +153,21 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
const oneYear = 365 * 24 * time.Hour
type lavenderLoginData struct {
UserInfo UserInfoFields `json:"user_info"`
auth.AccessTokenClaims
}
func (l lavenderLoginData) Valid() error { return nil }
func (l lavenderLoginData) Type() string { return "lavender-login-data" }
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth) bool {
ps := claims.NewPermStorage()
gen, err := h.signingKey.GenerateJwt(authData.ID, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, auth.AccessTokenClaims{Perms: ps})
gen, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, lavenderLoginData{
UserInfo: authData.UserInfo,
AccessTokenClaims: auth.AccessTokenClaims{Perms: ps},
})
if err != nil {
http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
return true
@ -174,34 +183,20 @@ func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAut
return false
}
func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) {
func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) error {
loginCookie, err := req.Cookie("lavender-login-data")
if err != nil {
return
return err
}
hexData, err := hex.DecodeString(loginCookie.Value)
_, b, err := mjwt.ExtractClaims[lavenderLoginData](h.signingKey, loginCookie.Value)
if err != nil {
return
return err
}
decData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), hexData, []byte("lavender-login-data"))
if err != nil {
return
*u = UserAuth{
Subject: b.Subject,
UserInfo: b.Claims.UserInfo,
}
userId := string(decData)
var token oauth2.Token
if h.DbTxRaw(func(tx *database.Tx) error {
return tx.GetUserToken(userId, &token.AccessToken, &token.RefreshToken, &token.Expiry)
}) {
return
}
sso := h.manager.FindServiceFromLogin(userId)
if sso == nil {
return
}
*u, _ = h.fetchUserInfo(sso, &token)
return nil
}
func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
@ -221,10 +216,8 @@ func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Toke
}
subject += "@" + sso.Config.Namespace
displayName := userInfoJson.GetStringOrDefault("name", "Unknown Name")
return UserAuth{
ID: subject,
DisplayName: displayName,
Subject: subject,
UserInfo: userInfoJson,
}, nil
}

View File

@ -26,11 +26,11 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
var roles string
var appList []database.ClientInfoDbOutput
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.ID)
roles, err = tx.GetUserRoles(auth.Subject)
if err != nil {
return
}
appList, err = tx.GetAppList(auth.ID, HasRole(roles, "lavender:admin"), offset)
appList, err = tx.GetAppList(auth.Subject, HasRole(roles, "lavender:admin"), offset)
return
}) {
return
@ -80,7 +80,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
if sso || hasPerms {
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.ID)
roles, err = tx.GetUserRoles(auth.Subject)
return
}) {
return
@ -98,7 +98,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
switch action {
case "create":
if h.DbTx(rw, func(tx *database.Tx) error {
return tx.InsertClientApp(name, domain, auth.ID, perms, public, sso, active)
return tx.InsertClientApp(name, domain, auth.Subject, perms, public, sso, active)
}) {
return
}
@ -108,7 +108,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
if err != nil {
return err
}
return tx.UpdateClientApp(sub, auth.ID, name, domain, perms, hasPerms, public, sso, active)
return tx.UpdateClientApp(sub, auth.Subject, name, domain, perms, hasPerms, public, sso, active)
}) {
return
}
@ -124,7 +124,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
if err != nil {
return err
}
secret, err = tx.ResetClientAppSecret(sub, auth.ID)
secret, err = tx.ResetClientAppSecret(sub, auth.Subject)
return err
}) {
return

View File

@ -24,7 +24,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
var roles string
var userList []database.User
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.ID)
roles, err = tx.GetUserRoles(auth.Subject)
if err != nil {
return
}
@ -43,7 +43,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
"Users": userList,
"Offset": offset,
"EmailShow": req.URL.Query().Has("show-email"),
"CurrentAdmin": auth.ID,
"CurrentAdmin": auth.Subject,
}
if q.Has("edit") {
for _, i := range userList {
@ -71,7 +71,7 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request,
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.ID)
roles, err = tx.GetUserRoles(auth.Subject)
return
}) {
return

View File

@ -147,5 +147,5 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
return "", nil
}
return auth.ID, nil
return auth.Subject, nil
}

View File

@ -1,7 +1,6 @@
package server
import (
"bytes"
"crypto/subtle"
"encoding/json"
"github.com/1f349/cache"
@ -10,8 +9,8 @@ import (
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/openid"
"github.com/1f349/lavender/pages"
scope2 "github.com/1f349/lavender/scope"
"github.com/1f349/lavender/theme"
"github.com/1f349/mjwt"
"github.com/go-oauth2/oauth2/v4/errors"
"github.com/go-oauth2/oauth2/v4/manage"
@ -20,6 +19,7 @@ import (
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
"path"
"strings"
"time"
)
@ -44,6 +44,7 @@ type flowStateData struct {
func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server {
r := httprouter.New()
contentCache := time.Now()
// remove last slash from baseUrl
{
@ -139,8 +140,14 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser
}))
// theme styles
r.GET("/theme/style.css", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
http.ServeContent(rw, req, "style.css", time.Now(), bytes.NewReader(theme.DefaultThemeCss))
r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
name := params.ByName("filepath")
if strings.Contains(name, "..") {
http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
out := pages.RenderCss(path.Join("assets", name))
http.ServeContent(rw, req, path.Base(name), contentCache, out)
})
// management pages

View File

@ -1,6 +0,0 @@
package theme
import _ "embed"
//go:embed style.css
var DefaultThemeCss []byte

15
utils/once.go Normal file
View File

@ -0,0 +1,15 @@
package utils
import "sync"
type Once[T any] struct {
once sync.Once
value T
}
func (o *Once[T]) Do(f func() T) T {
o.once.Do(func() {
o.value = f()
})
return o.value
}