Merge pull request 'develop' (#31) from develop into main

Reviewed-on: #31
This commit is contained in:
Zeni Kim 2026-05-19 00:42:37 -04:00
commit ec47943a39
8 changed files with 252 additions and 20 deletions

View file

@ -24,6 +24,8 @@ type GormConfig struct {
type QueueConfig struct {
EnableQueue bool
Concurrency int
Queues map[string]int
}
type CacheConfig struct {

View file

@ -34,6 +34,7 @@ type Context struct {
GetMailer func() *Mailer
GetEventsManager func() *EventsManager
GetLogger func() *logger.Logger
GetSession func() *SessionUser
}
// TODO enhance

View file

@ -19,6 +19,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// ErrValueTooLong indicates that the cookie value exceeds the allowed length limit.
@ -84,9 +85,8 @@ func GetCookie(r *http.Request) (UserCookie, error) {
}
// SetCookie sets an encrypted cookie with a user's email and token, using gob encoding for data serialization.
// The Secure flag is controlled by the COOKIE_SECURE environment variable (defaults to true, set to false for local HTTP development).
func SetCookie(w http.ResponseWriter, email string, token string) error {
// Initialize a User struct containing the data that we want to store in the
// cookie.
var err error
// check if template engine is enable
@ -124,15 +124,36 @@ func SetCookie(w http.ResponseWriter, email string, token string) error {
return err
}
// Derive cookie MaxAge from JWT_LIFESPAN_MINUTES (default: 1440 min = 1 day)
maxAge := 1440 * 60 // default 1 day in seconds
lifetimeStr := os.Getenv("JWT_LIFESPAN_MINUTES")
if lifetimeStr != "" {
lifetime, parseErr := strconv.Atoi(lifetimeStr)
if parseErr == nil {
maxAge = lifetime * 60 // convert minutes to seconds
}
}
// Determine if the cookie should have the Secure flag.
// Set COOKIE_SECURE=false (or "0", "f") in your .env for local development over HTTP.
// Defaults to true for production safety.
cookieSecureStr := os.Getenv("COOKIE_SECURE")
if cookieSecureStr == "" {
cookieSecureStr = "true"
}
cookieSecure, _ := strconv.ParseBool(cookieSecureStr)
// Call buf.String() to get the gob-encoded value as a string and set it as
// the cookie value.
cookie := http.Cookie{
Name: "goffee",
Value: buf.String(),
Path: "/",
MaxAge: 3600,
MaxAge: maxAge,
Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: cookieSecure,
}
// Write an encrypted cookie containing the gob-encoded data as normal.

31
core.go
View file

@ -26,6 +26,7 @@ import (
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
var loggr *logger.Logger
@ -276,6 +277,7 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha
GetMailer: resolveMailer(),
GetEventsManager: resolveEventsManager(),
GetLogger: resolveLogger(),
GetSession: getSession(),
}
ctx.prepare(ctx)
@ -307,7 +309,11 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha
w.WriteHeader(ctx.Response.statusCode)
}
if ctx.Response.redirectTo != "" {
http.Redirect(w, r, ctx.Response.redirectTo, http.StatusTemporaryRedirect)
statusCode := ctx.Response.redirectStatusCode
if statusCode == 0 {
statusCode = http.StatusTemporaryRedirect // default to 307
}
http.Redirect(w, r, ctx.Response.redirectTo, statusCode)
} else {
w.Write(ctx.Response.body)
}
@ -517,13 +523,18 @@ func NewGorm() *gorm.DB {
if err != nil {
panic(fmt.Sprintf("error locating sqlite file: %v", err.Error()))
}
db, err = gorm.Open(sqlite.Open(fullSqlitePath), &gorm.Config{})
db, err = gorm.Open(sqlite.Open(fullSqlitePath), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
default:
panic("database driver not selected")
}
if gormC.EnableGorm && err != nil {
panic(fmt.Sprintf("gorm has problem connecting to %v, (if it's not needed you can disable it in config/gorm.go): %v", os.Getenv("DB_DRIVER"), err))
}
if db != nil {
db.Logger = db.Logger.LogMode(gormlogger.Silent)
}
return db
}
@ -560,7 +571,9 @@ func postgresConnect() (*gorm.DB, error) {
os.Getenv("POSTGRES_SSL_MODE"),
os.Getenv("POSTGRES_TIMEZONE"),
)
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
return gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
}
// mysqlConnect establishes a connection to a MySQL database using credentials and configurations from environment variables.
@ -581,7 +594,9 @@ func mysqlConnect() (*gorm.DB, error) {
DontSupportRenameIndex: true, // drop & create when rename index, rename index not supported before MySQL 5.7, MariaDB
DontSupportRenameColumn: true, // `change` when rename column, rename column not supported before MySQL 8, MariaDB
SkipInitializeWithVersion: false, // auto configure based on currently MySQL version
}), &gorm.Config{})
}), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
}
// getJWT returns a function that initializes and provides a *JWT instance configured with environment variables.
@ -671,6 +686,14 @@ func resolveLogger() func() *logger.Logger {
return f
}
// getSession returns a function that creates and returns a new SessionUser instance when invoked.
func getSession() func() *SessionUser {
f := func() *SessionUser {
return &SessionUser{}
}
return f
}
// MakeDirs creates the specified directories under the base path with the provided permissions (0766).
func (app *App) MakeDirs(dirs ...string) {
o := syscall.Umask(0)

View file

@ -27,22 +27,32 @@ func (q *Queuemux) AddWork(pattern string, work Asynqtask) {
q.themux.HandleFunc(pattern, work)
}
// RunQueueserver starts the queue server with predefined configurations and handles tasks using the assigned ServeMux.
// RunQueueserver starts the queue server with the given configuration and handles tasks using the assigned ServeMux.
// Configures the queue server with concurrency limits and priority-based queue management.
// Logs and terminates the program if the server fails to run.
func (q *Queuemux) RunQueueserver() {
func (q *Queuemux) RunQueueserver(config QueueConfig) {
redisAddr := fmt.Sprintf("%v:%v", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT"))
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{Concurrency: 10,
// Optionally specify multiple queues with different priority.
Queues: map[string]int{
queues := config.Queues
if queues == nil {
queues = map[string]int{
"critical": 6,
"default": 3,
"low": 1,
},
}
}
concurrency := config.Concurrency
if concurrency <= 0 {
concurrency = 10
}
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: concurrency,
Queues: queues,
},
)

View file

@ -20,6 +20,7 @@ type Response struct {
overrideContentType string
isTerminated bool
redirectTo string
redirectStatusCode int
HttpResponseWriter http.ResponseWriter
}
@ -96,7 +97,7 @@ func (rs *Response) Template(name string, data interface{}) *Response {
panic(fmt.Sprintf("error executing template: %v", err))
}
rs.contentType = CONTENT_TYPE_HTML
buffer.WriteTo(rs.HttpResponseWriter)
rs.body = buffer.Bytes()
}
return rs
}
@ -136,14 +137,23 @@ func (rs *Response) ForceSendResponse() {
rs.isTerminated = true
}
// updates the redirect URL for the response and returns the modified Response. Validates the URL before setting it.
func (rs *Response) Redirect(url string) *Response {
// Redirect sends a redirect response to the given URL.
// By default it uses 307 (Temporary Redirect) to preserve the HTTP method.
// Pass true as the second argument to use 303 (See Other), which changes POST to GET.
func (rs *Response) Redirect(url string, use303 ...bool) *Response {
validator := resolveValidator()
v := validator.Validate(map[string]interface{}{
"url": url,
}, map[string]interface{}{
"url": "url",
})
if len(use303) > 0 && use303[0] {
rs.redirectStatusCode = http.StatusSeeOther // 303
} else {
rs.redirectStatusCode = http.StatusTemporaryRedirect // 307 (default)
}
if v.Failed() {
if url[0:1] != "/" {
rs.redirectTo = "/" + url
@ -153,6 +163,7 @@ func (rs *Response) Redirect(url string) *Response {
return rs
}
rs.redirectTo = url
return rs
}

146
session.go Normal file
View file

@ -0,0 +1,146 @@
// Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
// Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file.
package core
import (
"crypto/md5"
"encoding/json"
"fmt"
"sync"
"time"
)
// SessionUser handles user session data management with thread-safe operations.
// Sessions are stored in the cache (Redis) and are tied to an authenticated user
// via JWT tokens stored in cookies.
type SessionUser struct {
mu sync.RWMutex
context *Context
userID uint
hashedSessionKey string
authenticated bool
sessionStart time.Time
values map[string]interface{}
}
// Init initializes the session by validating the user's JWT token from the cookie.
// It checks the cached token, verifies it matches, and loads any existing session data.
// Returns true if the session was successfully established.
func (s *SessionUser) Init(c *Context) bool {
s.context = c
// get cookie
usercookie, err := c.GetCookie()
if err != nil || usercookie.Token == "" {
return false
}
payload, err := c.GetJWT().DecodeToken(usercookie.Token)
if err != nil {
return false
}
userID := uint(c.CastToInt(payload["userID"]))
userAgent := c.GetUserAgent()
// verify token against cached value
hashedCacheKey := CreateAuthTokenHashedCacheKey(userID, userAgent)
cachedToken, err := c.GetCache().Get(hashedCacheKey)
if err != nil || cachedToken != usercookie.Token {
return false
}
// session established - load session data from cache
userAgent = c.GetUserAgent()
sessionKey := fmt.Sprintf("sess_%v", userAgent)
s.hashedSessionKey = CreateAuthTokenHashedCacheKey(userID, sessionKey)
s.values = make(map[string]interface{})
s.authenticated = true
s.userID = userID
value, _ := c.GetCache().Get(s.hashedSessionKey)
if len(value) > 0 {
_ = json.Unmarshal([]byte(value), &s.values)
}
return true
}
// Set stores a value in the session and persists it to the cache.
func (s *SessionUser) Set(key string, value interface{}) error {
s.mu.Lock()
s.values[key] = value
s.mu.Unlock()
return s.Save()
}
// Get retrieves a value from the session. Returns the value and a boolean indicating if the key exists.
func (s *SessionUser) Get(key string) (interface{}, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.values[key]
return val, ok
}
// Delete removes a specific key from the session and persists the change.
// Returns the deleted value if it existed.
func (s *SessionUser) Delete(key string) interface{} {
s.mu.RLock()
v, ok := s.values[key]
s.mu.RUnlock()
if ok {
s.mu.Lock()
delete(s.values, key)
s.mu.Unlock()
}
s.Save()
return v
}
// Flush deletes all session data from the cache.
func (s *SessionUser) Flush() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.hashedSessionKey != "" {
_ = s.context.GetCache().Delete(s.hashedSessionKey)
}
s.values = make(map[string]interface{})
s.authenticated = false
return nil
}
// Save persists the current session values to the cache.
func (s *SessionUser) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.values) > 0 {
buf, err := json.Marshal(&s.values)
if err != nil {
return err
}
return s.context.GetCache().Set(s.hashedSessionKey, string(buf))
}
_ = s.context.GetCache().Delete(s.hashedSessionKey)
return nil
}
// GetUserID returns the user ID of the authenticated session user.
func (s *SessionUser) GetUserID() uint {
return s.userID
}
// IsAuthenticated returns whether the session has been successfully authenticated.
func (s *SessionUser) IsAuthenticated() bool {
return s.authenticated
}
// CreateAuthTokenHashedCacheKey generates a hashed cache key used to store JWT tokens and session data.
// The key is based on the user ID and user agent, hashed with MD5.
func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string {
cacheKey := fmt.Sprintf("userid:_%v_useragent:_%v_jwt_token", userID, userAgent)
return fmt.Sprintf("%x", md5.Sum([]byte(cacheKey)))
}

View file

@ -360,3 +360,21 @@ func NewTemplates(components embed.FS, templates embed.FS) {
)
tmpl = template.Must(tmpl.ParseFS(templates, pathst...))
}
// RenderNamedTemplate executes a named template from the registered set with
// the given data and returns the rendered HTML.
// Usage:
//
// html, err := core.RenderNamedTemplate("tabler_table", data)
// if err != nil {
// // handle error
// }
// return c.Response.HTML(string(html))
func RenderNamedTemplate(name string, data interface{}) (template.HTML, error) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", fmt.Errorf("failed to execute template %q: %w", name, err)
}
return template.HTML(buf.String()), nil
}