user login & middleware complete

This commit is contained in:
Simon Martens
2025-05-22 21:12:29 +02:00
parent 3f57e7a18d
commit 36e34d9e7b
17 changed files with 808 additions and 26 deletions

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/Theodor-Springmann-Stiftung/musenalm/views"
@@ -168,6 +169,9 @@ func (app *App) setWatchers(engine *templating.Engine) {
func (app *App) bindPages(engine *templating.Engine) ServeFunc {
return func(e *core.ServeEvent) error {
e.Router.GET("/assets/{path...}", apis.Static(views.StaticFS, true))
// INFO: Global middleware to get the authenticated user:
e.Router.BindFunc(middleware.Authenticated(e.App))
// INFO: we put this here, to make sure all migrations are done
for _, page := range pages {
err := page.Up(e.App, engine)

View File

@@ -503,6 +503,7 @@ const (
ITEMS_IDENTIFIER_FIELD = "identifier"
SESSIONS_TOKEN_FIELD = "token"
SESSIONS_CSRF_FIELD = "csrf"
SESSIONS_USER_FIELD = "user"
SESSIONS_IP_FIELD = "ip"
SESSIONS_USER_AGENT_FIELD = "agent"
@@ -512,8 +513,11 @@ const (
SESSIONS_PERSIST_FIELD = "persist"
USERS_TABLE = "users"
USERS_EMAIL_FIELD = "email"
USERS_SETTINGS_FIELD = "settings"
USERS_NAME_FIELD = "name"
USERS_ROLE_FIELD = "role"
USERS_AVATAR_FIELD = "avatar"
SESSION_COOKIE_NAME = "sid"
)

View File

@@ -0,0 +1,78 @@
package dbmodels
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types" // For types.NewDateTimeFromTime
)
const (
secureTokenByteLength = 64
)
func generateSecureRandomToken(length int) (string, error) {
if length <= 0 {
length = secureTokenByteLength
}
randomBytes := make([]byte, length)
_, err := rand.Read(randomBytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes for token: %w", err)
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func CreateSessionToken(
app core.App,
userID string,
ipAddress string,
userAgent string,
isPersistent bool,
sessionDuration time.Duration,
) (*Session, error) {
collection, err := app.FindCollectionByNameOrId(SESSIONS_TABLE)
if err != nil {
return nil, fmt.Errorf("failed to find '%s' collection: %w", SESSIONS_TABLE, err)
}
sessionTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
csrfTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
if err != nil {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
record := core.NewRecord(collection)
session := NewSession(record)
// Set required fields with hashed tokens
session.SetToken(sessionTokenClear)
session.SetCSRF(csrfTokenClear)
session.SetUser(userID)
date := types.NowDateTime()
expires := date.Add(sessionDuration)
session.SetExpires(expires)
session.SetPersist(isPersistent)
session.SetLastAccess(types.NowDateTime())
session.SetUserAgent(userAgent)
session.SetIP(ipAddress)
if errSave := app.Save(session); errSave != nil {
app.Logger().Error("Failed to save session token record", "error", errSave, "userID", userID)
return nil, fmt.Errorf("failed to save session token record: %w", errSave)
}
app.Logger().Info("Successfully created session token entry", "recordId", record.Id, "userID", userID)
return session, nil
}

View File

@@ -5,6 +5,24 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
type FixedSession struct {
ID string `json:"id"`
Token string `json:"token"`
User string `json:"user"`
Created string `json:"created"`
Updated string `json:"updated"`
Expires types.DateTime `json:"expires"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
LastAccess types.DateTime `json:"last_access"`
Persist bool `json:"persist"`
CSRF string `json:"csrf"`
}
func (s *FixedSession) IsExpired() bool {
return s.Expires.IsZero() || s.Expires.Before(types.NowDateTime())
}
var _ core.RecordProxy = (*Place)(nil)
type Session struct {
@@ -18,7 +36,7 @@ func NewSession(record *core.Record) *Session {
}
func (u *Session) TableName() string {
return USERS_TABLE
return SESSIONS_TABLE
}
func (u *Session) Token() string {
@@ -84,3 +102,31 @@ func (u *Session) Persist() bool {
func (u *Session) SetPersist(persist bool) {
u.Set(SESSIONS_PERSIST_FIELD, persist)
}
func (u *Session) CSRF() string {
return u.GetString(SESSIONS_CSRF_FIELD)
}
func (u *Session) SetCSRF(csrf string) {
u.Set(SESSIONS_CSRF_FIELD, csrf)
}
func (u *Session) IsExpired() bool {
return u.Expires().IsZero() || u.Expires().Before(types.NowDateTime())
}
func (u *Session) Fixed() FixedSession {
return FixedSession{
ID: u.Id,
Token: u.Token(),
User: u.User(),
Created: u.Created(),
Updated: u.Updated(),
Expires: u.Expires(),
IP: u.IP(),
UserAgent: u.UserAgent(),
LastAccess: u.LastAccess(),
Persist: u.Persist(),
CSRF: u.CSRF(),
}
}

View File

@@ -6,6 +6,18 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
type FixedUser struct {
Id string `json:"id"`
Email string `json:"email"`
Created types.DateTime `json:"created"`
Updated types.DateTime `json:"updated"`
Name string `json:"name"`
Role string `json:"role"`
Avatar string `json:"avatar"`
Verified bool `json:"verified"`
Settings string `json:"settings"`
}
var _ core.RecordProxy = (*Place)(nil)
type User struct {
@@ -22,7 +34,7 @@ func (u *User) TableName() string {
return USERS_TABLE
}
// INFO: Email is already set on the core.Record
// INFO: Email, password functions are already set on the core.Record
// TODO: We need to create a settings struct as soon as we have settings
func (u *User) Name() string {
return u.GetString(USERS_NAME_FIELD)
@@ -59,3 +71,16 @@ func (u *User) Avatar() string {
func (u *User) SetAvatar(avatar *filesystem.File) {
u.Set(USERS_AVATAR_FIELD, avatar)
}
func (u *User) Fixed() FixedUser {
return FixedUser{
Id: u.Id,
Email: u.Email(),
Created: u.Created(),
Updated: u.Updated(),
Name: u.Name(),
Role: u.Role(),
Avatar: u.Avatar(),
Verified: u.Verified(),
}
}

View File

@@ -0,0 +1,145 @@
package collections
import (
"sync"
"time"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
)
type cacheEntry struct {
user dbmodels.FixedUser
session dbmodels.FixedSession
}
type UserSessionCache struct {
mu sync.RWMutex
capacity int
cache sync.Map
approximateSize int
cleanupInterval time.Duration
stopCleanupSignal chan struct{}
}
func NewUserSessionCache(capacity int, cleanupInterval time.Duration) *UserSessionCache {
if capacity <= 0 {
capacity = 1000
}
if cleanupInterval <= 0 {
cleanupInterval = 5 * time.Minute
}
cache := &UserSessionCache{
capacity: capacity,
cache: sync.Map{},
cleanupInterval: cleanupInterval,
stopCleanupSignal: make(chan struct{}),
}
go cache.startCleanupRoutine()
return cache
}
func (c *UserSessionCache) Set(user *dbmodels.User, session *dbmodels.Session) (*dbmodels.FixedUser, *dbmodels.FixedSession) {
if user == nil || session == nil {
return nil, nil
}
newEntry := &cacheEntry{
user: user.Fixed(),
session: session.Fixed(),
}
_, loaded := c.cache.LoadOrStore(session.Token(), newEntry)
if !loaded {
c.cache.Store(session.Token(), newEntry)
c.mu.Lock()
c.approximateSize++
c.mu.Unlock()
}
return &newEntry.user, &newEntry.session
}
func (c *UserSessionCache) Get(sessionTokenClear string) (*dbmodels.FixedUser, *dbmodels.FixedSession, bool) {
if sessionTokenClear == "" {
return nil, nil, false
}
value, ok := c.cache.Load(sessionTokenClear)
if !ok {
return nil, nil, false
}
entry, ok := value.(*cacheEntry)
if !ok {
c.cache.Delete(sessionTokenClear)
return nil, nil, false
}
if time.Now().After(entry.session.Expires.Time()) {
c.cache.Delete(sessionTokenClear)
c.mu.Lock()
c.approximateSize--
c.mu.Unlock()
return nil, nil, false
}
return &entry.user, &entry.session, true
}
func (c *UserSessionCache) Delete(sessionTokenClear string) {
if sessionTokenClear == "" {
return
}
_, loaded := c.cache.LoadAndDelete(sessionTokenClear)
if loaded {
c.mu.Lock()
c.approximateSize--
c.mu.Unlock()
}
}
func (c *UserSessionCache) startCleanupRoutine() {
ticker := time.NewTicker(c.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanupExpiredItems()
case <-c.stopCleanupSignal:
return
}
}
}
func (c *UserSessionCache) cleanupExpiredItems() {
now := time.Now()
var newSize int
c.cache.Range(func(key, value any) bool {
entry, ok := value.(*cacheEntry)
if !ok {
c.cache.Delete(key)
return true
}
if now.After(entry.session.Expires.Time()) {
c.cache.Delete(key)
} else {
newSize++
}
return true
})
c.mu.Lock()
c.approximateSize = newSize
c.mu.Unlock()
}
func (c *UserSessionCache) StopCleanup() {
select {
case <-c.stopCleanupSignal:
default:
close(c.stopCleanupSignal)
}
}

View File

@@ -0,0 +1,188 @@
package security
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"sync"
"time"
)
// --- NonceCache ---
type nonceEntry struct {
expiresAt time.Time
}
// NonceCache stores nonces and their expiration times.
// It is safe for concurrent use.
type NonceCache struct {
mu sync.RWMutex
nonces map[string]nonceEntry
defaultExpiration time.Duration
cleanupInterval time.Duration
stopCleanup chan struct{} // Channel to signal cleanup goroutine to stop
}
// NewNonceCache creates a new in-memory nonce cache.
// defaultExpiration: The default duration for which a nonce is valid.
// cleanupInterval: How often to scan for and remove expired nonces.
func NewNonceCache(defaultExpiration, cleanupInterval time.Duration) *NonceCache {
if defaultExpiration <= 0 {
defaultExpiration = 15 * time.Minute // Default to 15 minutes if invalid
}
if cleanupInterval <= 0 {
cleanupInterval = 5 * time.Minute // Default to 5 minutes if invalid
}
nc := &NonceCache{
nonces: make(map[string]nonceEntry),
defaultExpiration: defaultExpiration,
cleanupInterval: cleanupInterval,
stopCleanup: make(chan struct{}),
}
go nc.startCleanupRoutine()
return nc
}
func (nc *NonceCache) Add(nonce string) {
nc.addWithExpiration(nonce, nc.defaultExpiration)
}
func (nc *NonceCache) addWithExpiration(nonce string, expiresIn time.Duration) {
if nonce == "" {
return
}
nc.mu.Lock()
defer nc.mu.Unlock()
nc.nonces[nonce] = nonceEntry{expiresAt: time.Now().Add(expiresIn)}
}
func (nc *NonceCache) Use(nonce string) bool {
if nonce == "" {
return false
}
nc.mu.Lock()
defer nc.mu.Unlock()
entry, exists := nc.nonces[nonce]
if !exists {
return false
}
if time.Now().After(entry.expiresAt) {
delete(nc.nonces, nonce)
return false
}
delete(nc.nonces, nonce)
return true
}
func (nc *NonceCache) startCleanupRoutine() {
ticker := time.NewTicker(nc.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
nc.cleanupExpiredNonces()
case <-nc.stopCleanup:
return
}
}
}
func (nc *NonceCache) cleanupExpiredNonces() {
nc.mu.Lock()
defer nc.mu.Unlock()
now := time.Now()
slog.Debug("Cleaning up expired nonces", "current_time", now, "nonces_count", len(nc.nonces))
for nonce, entry := range nc.nonces {
if now.After(entry.expiresAt) {
delete(nc.nonces, nonce)
}
}
}
func (nc *NonceCache) StopCleanup() {
select {
case <-nc.stopCleanup:
default:
close(nc.stopCleanup)
}
}
const (
defaultServerSecretSize = 32 // bytes
defaultNonceSize = 16 // bytes for raw nonce before encoding
)
type CSRFProtector struct {
serverSecret []byte
nonceCache *NonceCache
}
func NewCSRFProtector(nonceExpiration, nonceCleanupInterval time.Duration) (*CSRFProtector, error) {
secretToUse := make([]byte, defaultServerSecretSize)
if _, err := rand.Read(secretToUse); err != nil {
return nil, fmt.Errorf("failed to generate server secret: %w", err)
}
return &CSRFProtector{
serverSecret: secretToUse,
nonceCache: NewNonceCache(nonceExpiration, nonceCleanupInterval),
}, nil
}
func (p *CSRFProtector) GenerateTokenBundle() (nonceB64 string, validationTokenB64 string, err error) {
nonceBytes := make([]byte, defaultNonceSize)
if _, errRand := rand.Read(nonceBytes); errRand != nil {
return "", "", fmt.Errorf("failed to generate nonce bytes: %w", errRand)
}
nonceB64 = base64.URLEncoding.EncodeToString(nonceBytes)
p.nonceCache.Add(nonceB64)
mac := hmac.New(sha256.New, p.serverSecret)
mac.Write([]byte(nonceB64)) // Sign the base64 encoded nonce string
validationTokenBytes := mac.Sum(nil)
validationTokenB64 = base64.URLEncoding.EncodeToString(validationTokenBytes)
return nonceB64, validationTokenB64, nil
}
func (p *CSRFProtector) ValidateTokenBundle(nonceSubmittedB64 string, validationTokenSubmittedB64 string) (bool, error) {
if nonceSubmittedB64 == "" || validationTokenSubmittedB64 == "" {
return false, errors.New("submitted nonce or validation token is empty")
}
mac := hmac.New(sha256.New, p.serverSecret)
mac.Write([]byte(nonceSubmittedB64))
expectedMACTokenBytes := mac.Sum(nil)
validationTokenSubmittedBytes, err := base64.URLEncoding.DecodeString(validationTokenSubmittedB64)
if err != nil {
return false, fmt.Errorf("failed to decode submitted validation token: %w", err)
}
if !hmac.Equal(validationTokenSubmittedBytes, expectedMACTokenBytes) {
return false, errors.New("validation token (HMAC) mismatch")
}
if !p.nonceCache.Use(nonceSubmittedB64) {
return false, errors.New("nonce not found in cache, expired, or already used")
}
return true, nil
}
func (p *CSRFProtector) StopNonceCacheCleanup() {
if p.nonceCache != nil {
p.nonceCache.StopCleanup()
}
}

View File

@@ -0,0 +1,72 @@
package middleware
import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/collections"
"github.com/pocketbase/pocketbase/core"
)
var cache = collections.NewUserSessionCache(1000, 5*time.Minute)
var deact_cookie = &http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME,
MaxAge: -1,
Path: "/",
}
func Authenticated(app core.App) func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if strings.HasPrefix(e.Request.URL.Path, "/assets") ||
strings.HasPrefix(e.Request.URL.Path, "/api") {
return e.Next()
}
cookie, err := e.Request.Cookie(dbmodels.SESSION_COOKIE_NAME)
if err != nil {
return e.Next()
}
user, session, loaded := cache.Get(cookie.Value)
if !loaded {
record, err := app.FindFirstRecordByData(dbmodels.SESSIONS_TABLE, dbmodels.SESSIONS_TOKEN_FIELD, cookie.Value)
if err != nil {
e.SetCookie(deact_cookie)
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
return e.Next()
}
s := dbmodels.NewSession(record)
r, err := app.FindRecordById(dbmodels.USERS_TABLE, s.User())
if err != nil {
e.SetCookie(deact_cookie)
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
return e.Next()
}
u := dbmodels.NewUser(r)
user, session = cache.Set(u, s)
}
slog.Debug("User session detected", "user", user.Id, "name", user.Name, "session", session.ID)
if session.IsExpired() {
slog.Warn("Session expired", "user", user.Id, "name", user.Name, "session", session.ID)
cache.Delete(cookie.Value)
r, err := app.FindRecordById(dbmodels.SESSIONS_TABLE, session.ID)
e.SetCookie(deact_cookie)
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
if err == nil {
app.Delete(r)
}
return e.Next()
}
e.Set("user", user)
e.Set("session", session)
return e.Next()
}
}

View File

@@ -54,6 +54,11 @@ func sessionTokensFields(usersCollectionId string) core.FieldsList {
Required: true,
Presentable: false,
},
&core.TextField{
Name: dbmodels.SESSIONS_CSRF_FIELD,
Required: true,
Presentable: false,
},
&core.RelationField{
Name: dbmodels.SESSIONS_USER_FIELD,
Required: true,
@@ -80,7 +85,6 @@ func sessionTokensFields(usersCollectionId string) core.FieldsList {
},
&core.BoolField{
Name: dbmodels.SESSIONS_PERSIST_FIELD,
Required: true,
Presentable: true,
},
)

View File

@@ -27,7 +27,7 @@ func init() {
Required: true,
Presentable: true,
MaxSelect: 1,
Values: []string{"admin", "editor", "viewer"},
Values: dbmodels.USER_ROLES,
}
collection.Fields.Add(settingsField)

143
pages/login.go Normal file
View File

@@ -0,0 +1,143 @@
package pages
import (
"fmt"
"net/http"
"time"
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/security"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
URL_LOGIN = "/login/"
TEMPLATE_LOGIN = "/login/"
)
// TODO:
// - rate limiting
// - maybe csrf
func init() {
csrf_cache, err := security.NewCSRFProtector(time.Minute*5, time.Minute)
if err != nil {
panic(err)
}
lp := &LoginPage{
StaticPage: pagemodels.StaticPage{
Name: pagemodels.P_LOGIN_NAME,
Layout: "blank",
Template: TEMPLATE_LOGIN,
URL: URL_LOGIN,
},
csrf_cache: csrf_cache,
}
app.Register(lp)
}
type LoginPage struct {
pagemodels.StaticPage
csrf_cache *security.CSRFProtector
}
func (p *LoginPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
router.GET(URL_LOGIN, p.GET(engine))
router.POST(URL_LOGIN, p.POST(engine, app))
return nil
}
func (p *LoginPage) GET(engine *templating.Engine) HandleFunc {
return func(e *core.RequestEvent) error {
data := make(map[string]any)
data["record"] = p
nonce, token, err := p.csrf_cache.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
// TODO: the function to delete tokens is not yet there
// as of right now, the tokens get only deleted from the clients
// We need to delete the tokens from the cache + table.
e.SetCookie(&http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME,
Path: "/",
MaxAge: -1,
})
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
return engine.Response200(e, p.Template, data, p.Layout)
}
}
func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
data := make(map[string]any)
data["record"] = p
formdata := struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
CsrfNonce string `json:"csrf_nonce" form:"csrf_nonce"`
CsrfToken string `json:"csrf_token" form:"csrf_token"`
Persistent string `json:"persist" form:"persist"`
}{}
if err := e.BindBody(&formdata); err != nil {
return engine.Response500(e, err, data)
}
if _, err := p.csrf_cache.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil {
return engine.Response403(e, err, data)
}
if formdata.Username == "" || formdata.Password == "" {
return engine.Response403(e, fmt.Errorf("Username and password are required"), data)
}
record, err := app.FindFirstRecordByData(dbmodels.USERS_TABLE, dbmodels.USERS_EMAIL_FIELD, formdata.Username)
if err != nil || !record.ValidatePassword(formdata.Password) {
return engine.Response403(e, err, data)
}
duration := time.Minute * 60
if formdata.Persistent == "on" {
duration = time.Hour * 24 * 90
}
token, err := dbmodels.CreateSessionToken(app, record.Id, e.RealIP(), e.Request.UserAgent(), formdata.Persistent == "on", duration)
if err != nil {
return engine.Response500(e, err, data)
}
if formdata.Persistent == "on" {
e.SetCookie(&http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME,
Path: "/",
MaxAge: int(duration.Seconds()),
Value: token.Token(),
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
Secure: true,
})
} else {
e.SetCookie(&http.Cookie{
Name: dbmodels.SESSION_COOKIE_NAME,
Path: "/",
Value: token.Token(),
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
Secure: true,
})
}
return e.Redirect(303, "/reihen")
}
}

View File

@@ -67,7 +67,7 @@ func (p *ReihenPage) Setup(router *router.Router[*core.RequestEvent], app core.A
func (p *ReihenPage) YearRequest(app core.App, engine *templating.Engine, e *core.RequestEvent) error {
year := e.Request.URL.Query().Get(PARAM_YEAR)
data := map[string]interface{}{}
data := make(map[string]any)
data[PARAM_HIDDEN] = e.Request.URL.Query().Get(PARAM_HIDDEN)
data[PARAM_YEAR] = year

View File

@@ -4,11 +4,13 @@ import (
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
)
type HandleFunc func(e *core.RequestEvent) error
func init() {
RegisterStaticPage("/datenschutz/", pagemodels.P_DATENSCHUTZ_NAME)
RegisterStaticPage("/login/", pagemodels.P_LOGIN_NAME, "blank")
RegisterTextPage("/redaktion/kontakt/", pagemodels.P_KONTAKT_NAME)
RegisterTextPage("/redaktion/danksagungen/", pagemodels.P_DANK_NAME)
RegisterTextPage("/redaktion/literatur/", pagemodels.P_LIT_NAME)

View File

@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/functions"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/net/websocket"
@@ -70,7 +71,7 @@ type Engine struct {
mu *sync.Mutex
FuncMap template.FuncMap
GlobalData map[string]interface{}
GlobalData map[string]any
}
// INFO: We pass the app here to be able to access the config and other data for functions
@@ -82,7 +83,7 @@ func NewEngine(layouts, templates *fs.FS) *Engine {
LayoutRegistry: NewLayoutRegistry(*layouts),
TemplateRegistry: NewTemplateRegistry(*templates),
FuncMap: make(template.FuncMap),
GlobalData: make(map[string]interface{}),
GlobalData: make(map[string]any),
}
e.funcs()
return &e
@@ -142,7 +143,7 @@ func (e *Engine) funcs() error {
return nil
}
func (e *Engine) Globals(data map[string]interface{}) {
func (e *Engine) Globals(data map[string]any) {
e.mu.Lock()
defer e.mu.Unlock()
if e.GlobalData == nil {
@@ -186,13 +187,13 @@ func (e *Engine) Refresh() {
}
// INFO: fn is a function that returns either one value or two values, the second one being an error
func (e *Engine) AddFunc(name string, fn interface{}) {
func (e *Engine) AddFunc(name string, fn any) {
e.mu.Lock()
defer e.mu.Unlock()
e.FuncMap[name] = fn
}
func (e *Engine) AddFuncs(funcs map[string]interface{}) {
func (e *Engine) AddFuncs(funcs map[string]any) {
e.mu.Lock()
defer e.mu.Unlock()
for k, v := range funcs {
@@ -200,10 +201,10 @@ func (e *Engine) AddFuncs(funcs map[string]interface{}) {
}
}
func (e *Engine) Render(out io.Writer, path string, ld map[string]interface{}, layout ...string) error {
func (e *Engine) Render(out io.Writer, path string, ld map[string]any, layout ...string) error {
gd := e.GlobalData
if ld == nil {
ld = make(map[string]interface{})
ld = make(map[string]any)
}
// INFO: don't pollute the global data space
@@ -251,9 +252,30 @@ func (e *Engine) Render(out io.Writer, path string, ld map[string]interface{}, l
return nil
}
func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]interface{}) error {
func (e *Engine) Response403(request *core.RequestEvent, err error, data map[string]any) error {
if data == nil {
data = make(map[string]interface{})
data = make(map[string]any)
}
var sb strings.Builder
if err != nil {
request.App.Logger().Error("Unauthorized 403 error fetching URL!", "error", err, "request", request.Request.URL)
data["Error"] = err.Error()
}
data["page"] = requestData(request)
err2 := e.Render(&sb, "/errors/403/", data)
if err2 != nil {
return e.Response500(request, errors.Join(err, err2), data)
}
return request.HTML(http.StatusNotFound, sb.String())
}
func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]any) error {
if data == nil {
data = make(map[string]any)
}
var sb strings.Builder
@@ -272,9 +294,9 @@ func (e *Engine) Response404(request *core.RequestEvent, err error, data map[str
return request.HTML(http.StatusNotFound, sb.String())
}
func (e *Engine) Response500(request *core.RequestEvent, err error, data map[string]interface{}) error {
func (e *Engine) Response500(request *core.RequestEvent, err error, data map[string]any) error {
if data == nil {
data = make(map[string]interface{})
data = make(map[string]any)
}
var sb strings.Builder
@@ -293,9 +315,9 @@ func (e *Engine) Response500(request *core.RequestEvent, err error, data map[str
return request.HTML(http.StatusInternalServerError, sb.String())
}
func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[string]interface{}, layout ...string) error {
func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[string]any, layout ...string) error {
if ld == nil {
ld = make(map[string]interface{})
ld = make(map[string]any)
}
ld["page"] = requestData(request)
@@ -317,11 +339,26 @@ func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[str
return request.HTML(http.StatusOK, tstring)
}
func requestData(request *core.RequestEvent) map[string]interface{} {
data := make(map[string]interface{})
func requestData(request *core.RequestEvent) map[string]any {
data := make(map[string]any)
data["Path"] = request.Request.URL.Path
data["Query"] = request.Request.URL.Query()
data["Method"] = request.Request.Method
data["Host"] = request.Request.Host
if user := request.Get("user"); user != nil {
u, ok := user.(*dbmodels.FixedUser)
if ok {
data["User"] = u
}
}
if session := request.Get("session"); session != nil {
u, ok := session.(*dbmodels.FixedSession)
if ok {
data["Session"] = u
}
}
return data
}

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,17 @@
<i class="ri-code-line"></i>
<a href="https://github.com/Theodor-Springmann-Stiftung/musenalm">Code</a>
<span>&middot;</span>
<i class="ri-login-box-line"></i>
<a href="/login">Login</a>
{{ if .page.User }}
<i class="ri-user-3-line"></i>
Eingeloggt als
{{ .page.User.Email }}
|
<i class="ri-logout-box-line"></i>
<a href="/login">Logout</a>
{{ else }}
<i class="ri-login-box-line"></i>
<a href="/login">Login</a>
{{ end }}
</div>
</div>
</footer>

View File

@@ -1,3 +1,6 @@
{{ $model := . }}
<div class="flex max-w-md mx-auto !pt-44">
<div class="flex-col w-full">
<a href="/" class="text-gray-700 hover:text-slate-950">
@@ -9,7 +12,7 @@
</div>
</div>
<h1 class="text-4xl self-baseline text-center w-full mt-6">Musenalm | Login</h1>
<form class="mt-9 w-full grid grid-cols-3 gap-4" method="POST" action="/login">
<form class="mt-9 w-full grid grid-cols-3 gap-4" method="POST" action="/login/">
<div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
@@ -17,7 +20,7 @@
E-Mail <i class="ri-at-line"></i>
</label>
<input
type="text"
type="email"
name="username"
id="username"
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
@@ -42,7 +45,29 @@
required
autocomplete="current-password" />
</div>
<div class="col-start-2 col-span-2">
<div class="flex justify-end items-center">
<input
type="checkbox"
name="persist"
id="persist"
class="h-4 w-4 text-slate-600 focus:ring-slate-500 border-gray-300 rounded" />
<label for="persist" class="ml-2 block text-sm text-gray-900"> Angemeldet bleiben </label>
</div>
</div>
<div class="col-span-3">
<input
type="hidden"
name="csrf_nonce"
id="csrf_nonce"
required
value="{{ $model.csrf_nonce }}" />
<input
type="hidden"
name="csrf_token"
id="csrf_token"
required
value="{{ $model.csrf_token }}" />
<button
type="submit"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent rounded-md