diff --git a/config.go b/config.go index c481de2..921cb24 100644 --- a/config.go +++ b/config.go @@ -24,6 +24,8 @@ type GormConfig struct { type QueueConfig struct { EnableQueue bool + Concurrency int + Queues map[string]int } type CacheConfig struct { diff --git a/context.go b/context.go index 9015159..028ac5a 100644 --- a/context.go +++ b/context.go @@ -34,6 +34,7 @@ type Context struct { GetMailer func() *Mailer GetEventsManager func() *EventsManager GetLogger func() *logger.Logger + GetSession func() *SessionUser } // TODO enhance diff --git a/cookies.go b/cookies.go index 4e51470..9830099 100644 --- a/cookies.go +++ b/cookies.go @@ -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. diff --git a/core.go b/core.go index 3846432..3af6adf 100644 --- a/core.go +++ b/core.go @@ -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) diff --git a/queue.go b/queue.go index 905a6ed..70de9cd 100644 --- a/queue.go +++ b/queue.go @@ -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")) + 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: 10, - // Optionally specify multiple queues with different priority. - Queues: map[string]int{ - "critical": 6, - "default": 3, - "low": 1, - }, + asynq.Config{ + Concurrency: concurrency, + Queues: queues, }, ) diff --git a/response.go b/response.go index d6202c7..12b6406 100644 --- a/response.go +++ b/response.go @@ -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 } diff --git a/session.go b/session.go new file mode 100644 index 0000000..16888de --- /dev/null +++ b/session.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Zeni Kim +// 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))) +} diff --git a/templates.go b/templates.go index 4ee0050..cf6692e 100644 --- a/templates.go +++ b/templates.go @@ -359,4 +359,22 @@ func NewTemplates(components embed.FS, templates embed.FS) { ParseFS(components, paths...), ) tmpl = template.Must(tmpl.ParseFS(templates, pathst...)) -} \ No newline at end of file +} + + +// 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 +}