1
0
Fork 0
forked from goffee/core

Compare commits

...
Sign in to create a new pull request.

69 commits

Author SHA1 Message Date
dc9a11fde3 Merge pull request 'develop' (#35) from develop into main
Reviewed-on: goffee/core#35
2026-06-23 20:29:34 -04:00
ab7c00575c Merge branch 'main' into develop 2026-06-23 20:29:14 -04:00
86ab226024 update golang packages 2026-06-23 19:21:39 -05:00
fc33756897 Merge pull request 'scheduler' (#34) from jacs/core:scheduler into develop
Reviewed-on: goffee/core#34
2026-06-23 19:55:57 -04:00
Jose Cely
987e8b98d0 scheduler updates 2026-06-23 18:12:36 -05:00
Jose Cely
d156e3785d add maxruns and option for loop forever 2026-06-22 11:48:20 -05:00
6bc768ba77 Merge pull request 'develop' (#33) from develop into main
Sheduler as core module feature, following the same pattern used for the Asynq queue system. The new core/scheduler/ subpackage contains QueueItem and ProcessedItem GORM models (tables queue_items and processed_items), a Store for database operations
2026-06-21 22:41:25 -04:00
7ec601ca10 Merge pull request 'Scheduler as a first-class core module feature, following the same pattern used for the Asynq queue system' (#32) from jacs/core:scheduler into develop
Reviewed-on: goffee/core#32
2026-06-21 22:09:23 -04:00
Jose Cely
3710fbe343 Create a scheduler as a first-class core module feature, following the same pattern used for the Asynq queue system. The new core/scheduler/ subpackage contains QueueItem and ProcessedItem GORM models (tables queue_items and processed_items), a Store for database operations (enqueue, claim batch with SELECT FOR UPDATE SKIP LOCKED, result recording, stuck recovery), a Semaphore kill switch, and the polling Scheduler loop with configurable interval and rate limit. The core/scheduler.go wrapper exposes a Schedulermux struct (analogous to Queuemux) with SchedulerInit(), AddWork(), SetStore(), and RunScheduler() methods, plus global accessors ResolveSchedulerStore() and SchedulerSemaphoreSetGreen/Red/IsGreen() so controllers can enqueue work and control the semaphore without importing the subpackage directly. 2026-06-13 18:19:01 -05:00
ec47943a39 Merge pull request 'develop' (#31) from develop into main
Reviewed-on: goffee/core#31
2026-05-19 00:42:37 -04:00
b757aef6b1 get queue configurations from config in cup 2026-05-18 22:34:37 -05:00
75ddca3e7d migrated from cup session to core/session.go, Contains the new SessionUser struct and the CreateAuthTokenHashedCacheKey utility function 2026-05-18 13:02:06 -05:00
7c164ef58f fix typo 2026-05-13 00:14:02 -05:00
d6dafa5ab6 fix typo 2026-05-13 00:10:30 -05:00
8604ce94ff fix order 2026-05-13 00:08:36 -05:00
8446f6fb2a In the core's response.go, add an optional RedirectCodeSetter field or variadic use303 ...bool parameter to Redirect() and use the stored code + http.StatusSeeOther in core.go instead of the hardcoded http.StatusTemporaryRedirect, so that a POST handler can redirect to a GET route by calling c.Response.Redirect("/url", true). 2026-05-12 23:21:03 -05:00
262c5befd9 - COOKIE_SECURE environment variable (defaults to true, set to false for local HTTP development)
- Cookie time and JWT share the same time
2026-05-11 21:35:00 -05:00
19dba8f504 - Add function RenderNamedTemplate executes a named template from the registered set with the given data and returns the rendered HTML.
- gormlogger.Silent is the log level that suppresses all Gorm log messages, including the "record not found" warnings.
2026-05-11 20:17:30 -05:00
59eadf29d0 fix bug Template Template compliance 2026-05-08 02:30:34 -05:00
6149d2945b Merge pull request 'develop' (#30) from develop into main
Reviewed-on: goffee/core#30
2026-05-06 21:59:23 -04:00
0da7ea8ab1 Added Level type - A custom int type with constants DEBUG, INFO, WARNING, ERROR in increasing severity order.
Added `minLevel` field to Logger struct to track the minimum level threshold.
Added `SetLevel()` and `GetLevel()` methods for dynamically changing/reading the log level at runtime.
2026-05-06 16:06:25 -05:00
5e389115fc Custom html/template FuncMap extensions that bring Liquid-like expressiveness to Go server-side templates. 2026-05-02 03:00:56 -05:00
851c9afd03 go update 2026-05-02 01:15:26 -05:00
78a06fb900 sync graph code 2026-05-02 00:37:10 -05:00
3d67bc0a03 Merge pull request 'develop' (#27) from develop into main
Reviewed-on: goffee/core#27
2025-09-18 01:12:53 -04:00
6b0e2e4739 Merge pull request 'main' (#26) from diana/core:main into develop
Reviewed-on: goffee/core#26
2025-09-18 01:11:31 -04:00
e8f7ab40b5 Implemented a thread-safe solution to the race condition that was causing random route overlaps 2025-09-18 00:11:08 -05:00
3c0b56c433 add id 2025-08-26 13:31:13 -05:00
5c6be4b037 Merge pull request 'main' (#6) from goffee/core:main into main
Reviewed-on: #6
2025-08-26 13:39:08 -04:00
850d2ae477 Merge pull request 'develop' (#25) from develop into main
Reviewed-on: goffee/core#25
2025-07-15 11:54:08 -04:00
5f0ca8e797 add options for cdn and app 2025-07-15 10:53:18 -05:00
c9cb539d18 check child items to active nav 2025-04-28 00:12:43 -05:00
30746a0602 check child items to active nav 2025-04-28 00:11:04 -05:00
e43ace6679 Merge pull request 'main' (#24) from main into develop
Reviewed-on: goffee/core#24
2025-04-28 00:47:40 -04:00
653fd5c64e add ID to PageNavItem 2025-04-27 23:41:25 -05:00
f1772b99f3 Merge pull request 'develop' (#23) from develop into main
Reviewed-on: goffee/core#23
2025-04-17 02:52:54 -04:00
f276f4d61d add element paginator 2025-04-17 01:49:43 -05:00
5c3559c793 Merge pull request 'template with select multiple, input custom attribute' (#22) from jacs/core:develop into develop
Reviewed-on: goffee/core#22
2025-04-16 20:59:45 -04:00
jacs
e3748c853f template with select multiple, input custom attribute 2025-04-15 07:23:34 -05:00
259f2f4b79 add img and class to table template 2025-03-27 12:41:17 -05:00
cc8c79fe3d add img and class to table template 2025-03-27 12:19:18 -05:00
db3c510f9a add img and class to table template, new responnse buffer inline 2025-03-27 12:12:49 -05:00
1a39c666a3 fix 2025-03-18 23:43:09 -05:00
790c840f76 split if 2025-03-18 20:14:52 -05:00
a71b3697b6 Merge pull request 'develop' (#21) from develop into main
Reviewed-on: goffee/core#21
2025-03-07 10:44:06 -05:00
530a1171e6 Merge pull request 'develop' (#20) from jacs/core:develop into develop
Reviewed-on: goffee/core#20
2025-03-07 10:42:26 -05:00
3b6fa12911 change panic by err 2025-03-05 15:12:11 -05:00
9816e58e7c Merge pull request 'main' (#1) from goffee/core:main into main
Reviewed-on: jacs/core#1
2025-03-05 11:54:06 -05:00
695f1f57ba add documentation 2025-02-24 09:59:07 -05:00
1b23363f6f Merge pull request 'add config option to disable queues' (#19) from develop into main
Reviewed-on: goffee/core#19
2024-12-23 23:44:23 -05:00
0db37d31b8 add config option to disable queues 2024-12-23 23:41:27 -05:00
deb119db84 Merge pull request 'develop' (#18) from develop into main
Reviewed-on: goffee/core#18
2024-12-23 23:13:02 -05:00
b274d3268f add base queue system 2024-12-23 23:11:49 -05:00
4968da25f3 change BufferPDF to BufferFile to support any kind of file 2024-12-22 10:42:56 -05:00
0afafd8c41 Merge pull request 'improve GetRequestForm, extend templates' (#17) from develop into main
Reviewed-on: goffee/core#17
2024-12-18 08:19:48 -05:00
90564daa5b improve GetRequestForm, extend templates 2024-12-17 14:26:57 -05:00
23753fc72f Merge pull request 'develop' (#16) from develop into main
Reviewed-on: goffee/core#16
2024-12-08 09:02:10 -05:00
a970ada00b Merge branch 'main' into develop 2024-12-08 09:01:53 -05:00
92f76c5f33 Merge pull request 'add buffer pdf response' (#15) from jacs/core:develop into develop
Reviewed-on: goffee/core#15
2024-12-08 08:58:08 -05:00
2076b4b35b add ID to dropdown 2024-12-08 08:52:35 -05:00
jacs
5d737c6b10 add buffer pdf response 2024-12-08 00:24:44 -05:00
3d0c238934 Merge pull request 'develop' (#14) from develop into main
Reviewed-on: goffee/core#14
2024-12-06 04:58:47 -05:00
5896e6e617 add ID to button 2024-12-06 04:55:43 -05:00
8950cb539a change to temporary 2024-11-22 16:25:06 -05:00
b87e0113c9 Merge pull request 'develop' (#12) from develop into main
Reviewed-on: goffee/core#12
2024-10-29 13:31:47 -04:00
f07131086a Merge pull request 'add prod mode' (#10) from develop into main
Reviewed-on: goffee/core#10
2024-10-29 07:53:38 -04:00
2a4a092cb2 Merge pull request 'develop' (#9) from develop into main
Reviewed-on: goffee/core#9
2024-10-28 13:20:05 -04:00
789a157571 Merge pull request 'develop' (#8) from develop into main
Reviewed-on: goffee/core#8
2024-10-23 08:11:16 -04:00
b9cd82867b Merge pull request 'develop' (#6) from develop into main
Reviewed-on: goffee/core#6
2024-10-16 00:04:58 -04:00
35 changed files with 2382 additions and 187 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
tmp tmp
.DS_STore .DS_STore
.idea

View file

@ -1,4 +1,3 @@
# GoCondor Core # Goffee Core
![Build Status](https://github.com/gocondor/core/actions/workflows/build-main.yml/badge.svg) ![Test Status](https://github.com/gocondor/core/actions/workflows/test-main.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/gocondor/core/badge.svg?branch=main)](https://coveralls.io/github/gocondor/core?branch=main&cache=false) [![GoDoc](https://godoc.org/github.com/gocondor/core?status.svg)](https://godoc.org/github.com/gocondor/core) [![Go Report Card](https://goreportcard.com/badge/github.com/gocondor/core)](https://goreportcard.com/report/github.com/gocondor/core)
The core packages of GoCondor framework The core packages of Goffee framework

View file

@ -16,6 +16,9 @@ type Cache struct {
redis *redis.Client redis *redis.Client
} }
// NewCache initializes a new Cache instance with the provided configuration and connects to a Redis database.
// If caching is enabled in the provided configuration but the connection to Redis fails, it causes a panic.
// Returns a pointer to the initialized Cache structure.
func NewCache(cacheConfig CacheConfig) *Cache { func NewCache(cacheConfig CacheConfig) *Cache {
ctx = context.Background() ctx = context.Background()
dbStr := os.Getenv("REDIS_DB") dbStr := os.Getenv("REDIS_DB")
@ -40,6 +43,7 @@ func NewCache(cacheConfig CacheConfig) *Cache {
} }
} }
// Set stores a key-value pair in the Redis cache with no expiration. Returns an error if the operation fails.
func (c *Cache) Set(key string, value string) error { func (c *Cache) Set(key string, value string) error {
err := c.redis.Set(ctx, key, value, 0).Err() err := c.redis.Set(ctx, key, value, 0).Err()
if err != nil { if err != nil {
@ -48,6 +52,8 @@ func (c *Cache) Set(key string, value string) error {
return nil return nil
} }
// SetWithExpiration stores a key-value pair in the cache with a specified expiration duration.
// Returns an error if the operation fails.
func (c *Cache) SetWithExpiration(key string, value string, expiration time.Duration) error { func (c *Cache) SetWithExpiration(key string, value string, expiration time.Duration) error {
err := c.redis.Set(ctx, key, value, expiration).Err() err := c.redis.Set(ctx, key, value, expiration).Err()
if err != nil { if err != nil {
@ -56,6 +62,7 @@ func (c *Cache) SetWithExpiration(key string, value string, expiration time.Dura
return nil return nil
} }
// Get retrieves the value associated with the provided key from the cache. Returns an error if the operation fails.
func (c *Cache) Get(key string) (string, error) { func (c *Cache) Get(key string) (string, error) {
result, err := c.redis.Get(ctx, key).Result() result, err := c.redis.Get(ctx, key).Result()
if err != nil { if err != nil {
@ -64,6 +71,7 @@ func (c *Cache) Get(key string) (string, error) {
return result, nil return result, nil
} }
// Delete removes the specified key from the cache and returns an error if the operation fails.
func (c *Cache) Delete(key string) error { func (c *Cache) Delete(key string) error {
err := c.redis.Del(ctx, key).Err() err := c.redis.Del(ctx, key).Err()
if err != nil { if err != nil {

View file

@ -1,10 +1,13 @@
// Copyright 2021 Harran Ali <harran.m@gmail.com>. All rights reserved. // Copyright 2021 Harran Ali <harran.m@gmail.com>. All rights reserved.
// Copyright (c) 2024 Zeni Kim <zenik@smarteching.com> // Copyright (c) 2024 Zeni Kim <zenik@smarteching.com>
// Copyright (c) 2026 Jose Cely <me@jacs.guru>
// Use of this source code is governed by MIT-style // Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package core package core
import "time"
type EnvFileConfig struct { type EnvFileConfig struct {
UseDotEnvFile bool UseDotEnvFile bool
} }
@ -22,6 +25,18 @@ type GormConfig struct {
EnableGorm bool EnableGorm bool
} }
type QueueConfig struct {
EnableQueue bool
Concurrency int
Queues map[string]int
}
type SchedulerConfig struct {
EnableScheduler bool
SchedulerInterval time.Duration
SchedulerRateLimit int
}
type CacheConfig struct { type CacheConfig struct {
EnableCache bool EnableCache bool
} }

View file

@ -19,6 +19,7 @@ import (
"syscall" "syscall"
"git.smarteching.com/goffee/core/logger" "git.smarteching.com/goffee/core/logger"
"github.com/hibiken/asynq"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -33,6 +34,7 @@ type Context struct {
GetMailer func() *Mailer GetMailer func() *Mailer
GetEventsManager func() *EventsManager GetEventsManager func() *EventsManager
GetLogger func() *logger.Logger GetLogger func() *logger.Logger
GetSession func() *SessionUser
} }
// TODO enhance // TODO enhance
@ -53,7 +55,11 @@ func (c *Context) Next() {
} }
func (c *Context) prepare(ctx *Context) { func (c *Context) prepare(ctx *Context) {
ctx.Request.httpRequest.ParseMultipartForm(int64(app.Config.Request.MaxUploadFileSize)) // Only parse multipart form if it hasn't been parsed already
// This prevents race conditions when multiple goroutines might access the same request
if ctx.Request.httpRequest.MultipartForm == nil {
ctx.Request.httpRequest.ParseMultipartForm(int64(app.Config.Request.MaxUploadFileSize))
}
} }
func (c *Context) GetPathParam(key string) interface{} { func (c *Context) GetPathParam(key string) interface{} {
@ -68,8 +74,9 @@ func (c *Context) RequestParamExists(key string) bool {
return c.Request.httpRequest.Form.Has(key) return c.Request.httpRequest.Form.Has(key)
} }
func (c *Context) GetRequesForm() interface{} { func (c *Context) GetRequesForm(key string) interface{} {
return c.Request.httpRequest.Form c.Request.httpRequest.ParseForm()
return c.Request.httpRequest.Form[key]
} }
func (c *Context) GetRequesBodyMap() map[string]interface{} { func (c *Context) GetRequesBodyMap() map[string]interface{} {
@ -111,34 +118,51 @@ func (c *Context) GetCookie() (UserCookie, error) {
return user, nil return user, nil
} }
func (c *Context) GetUploadedFile(name string) *UploadedFileInfo { func (c *Context) GetQueueClient() *asynq.Client {
redisAddr := fmt.Sprintf("%v:%v", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT"))
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
return client
}
func (c *Context) GetUploadedFile(name string) (*UploadedFileInfo, error) {
file, fileHeader, err := c.Request.httpRequest.FormFile(name) file, fileHeader, err := c.Request.httpRequest.FormFile(name)
if err != nil { if err != nil {
panic(fmt.Sprintf("error with file,[%v]", err.Error())) return nil, fmt.Errorf("error retrieving file: %v", err)
} }
defer file.Close() defer file.Close()
// Extract the file extension
ext := strings.TrimPrefix(path.Ext(fileHeader.Filename), ".") ext := strings.TrimPrefix(path.Ext(fileHeader.Filename), ".")
tmpFilePath := filepath.Join(os.TempDir(), fileHeader.Filename) tmpFilePath := filepath.Join(os.TempDir(), fileHeader.Filename)
tmpFile, err := os.Create(tmpFilePath) tmpFile, err := os.Create(tmpFilePath)
if err != nil { if err != nil {
panic(fmt.Sprintf("error with file,[%v]", err.Error())) return nil, fmt.Errorf("error creating temporary file: %v", err)
} }
defer tmpFile.Close()
// Copy the uploaded file content to the temporary file
buff := make([]byte, 100) buff := make([]byte, 100)
for { for {
n, err := file.Read(buff) n, err := file.Read(buff)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
panic("error with uploaded file") return nil, fmt.Errorf("error reading uploaded file: %v", err)
} }
if n == 0 { if n == 0 {
break break
} }
n, _ = tmpFile.Write(buff[:n]) _, err = tmpFile.Write(buff[:n])
if err != nil {
return nil, fmt.Errorf("error writing to temporary file: %v", err)
}
} }
// Get file info for the temporary file
tmpFileInfo, err := os.Stat(tmpFilePath) tmpFileInfo, err := os.Stat(tmpFilePath)
if err != nil { if err != nil {
panic(fmt.Sprintf("error with file,[%v]", err.Error())) return nil, fmt.Errorf("error getting file info: %v", err)
} }
defer tmpFile.Close()
uploadedFileInfo := &UploadedFileInfo{ uploadedFileInfo := &UploadedFileInfo{
FullPath: tmpFilePath, FullPath: tmpFilePath,
Name: fileHeader.Filename, Name: fileHeader.Filename,
@ -146,7 +170,7 @@ func (c *Context) GetUploadedFile(name string) *UploadedFileInfo {
Extension: ext, Extension: ext,
Size: int(tmpFileInfo.Size()), Size: int(tmpFileInfo.Size()),
} }
return uploadedFileInfo return uploadedFileInfo, nil
} }
func (c *Context) MoveFile(sourceFilePath string, destFolderPath string) error { func (c *Context) MoveFile(sourceFilePath string, destFolderPath string) error {
@ -175,7 +199,7 @@ func (c *Context) MoveFile(sourceFilePath string, destFolderPath string) error {
for { for {
n, err := srcFile.Read(buff) n, err := srcFile.Read(buff)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
panic(fmt.Sprintf("error moving file %v", sourceFilePath)) return fmt.Errorf("error moving file: %v", sourceFilePath)
} }
if n == 0 { if n == 0 {
break break
@ -219,7 +243,7 @@ func (c *Context) CopyFile(sourceFilePath string, destFolderPath string) error {
for { for {
n, err := srcFile.Read(buff) n, err := srcFile.Read(buff)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
panic(fmt.Sprintf("error moving file %v", sourceFilePath)) return fmt.Errorf("error moving file: %v", sourceFilePath)
} }
if n == 0 { if n == 0 {
break break

View file

@ -19,21 +19,27 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// ErrValueTooLong indicates that the cookie value exceeds the allowed length limit.
// ErrInvalidValue signifies that the cookie value is in an invalid format.
var ( var (
ErrValueTooLong = errors.New("cookie value too long") ErrValueTooLong = errors.New("cookie value too long")
ErrInvalidValue = errors.New("invalid cookie value") ErrInvalidValue = errors.New("invalid cookie value")
) )
// Declare the User type. // UserCookie represents a structure to hold user-specific data stored in a cookie, including Email and Token fields.
type UserCookie struct { type UserCookie struct {
Email string Email string
Token string Token string
} }
// secretcookie is a global variable used to store the decoded secret key for encrypting and decrypting cookies.
var secretcookie []byte var secretcookie []byte
// GetCookie retrieves and decrypts the user cookie from the provided HTTP request and returns it as a UserCookie.
// Returns an error if the cookie retrieval, decryption, or decoding process fails.
func GetCookie(r *http.Request) (UserCookie, error) { func GetCookie(r *http.Request) (UserCookie, error) {
var err error var err error
@ -78,9 +84,9 @@ 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 { 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 var err error
// check if template engine is enable // check if template engine is enable
@ -118,15 +124,36 @@ func SetCookie(w http.ResponseWriter, email string, token string) error {
return err 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 // Call buf.String() to get the gob-encoded value as a string and set it as
// the cookie value. // the cookie value.
cookie := http.Cookie{ cookie := http.Cookie{
Name: "goffee", Name: "goffee",
Value: buf.String(), Value: buf.String(),
Path: "/", Path: "/",
MaxAge: 3600, MaxAge: maxAge,
Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: cookieSecure,
} }
// Write an encrypted cookie containing the gob-encoded data as normal. // Write an encrypted cookie containing the gob-encoded data as normal.
@ -138,6 +165,8 @@ func SetCookie(w http.ResponseWriter, email string, token string) error {
return nil return nil
} }
// CookieWrite writes a secure HTTP cookie to the response writer after base64 encoding its value.
// Returns ErrValueTooLong if the cookie string exceeds the 4096-byte size limit.
func CookieWrite(w http.ResponseWriter, cookie http.Cookie) error { func CookieWrite(w http.ResponseWriter, cookie http.Cookie) error {
// Encode the cookie value using base64. // Encode the cookie value using base64.
cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value)) cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value))
@ -154,6 +183,8 @@ func CookieWrite(w http.ResponseWriter, cookie http.Cookie) error {
return nil return nil
} }
// CookieRead retrieves a base64-encoded cookie value by name from the HTTP request and decodes it.
// Returns the decoded value as a string or an error if the cookie is not found or the value is invalid.
func CookieRead(r *http.Request, name string) (string, error) { func CookieRead(r *http.Request, name string) (string, error) {
// Read the cookie as normal. // Read the cookie as normal.
cookie, err := r.Cookie(name) cookie, err := r.Cookie(name)
@ -173,6 +204,9 @@ func CookieRead(r *http.Request, name string) (string, error) {
return string(value), nil return string(value), nil
} }
// CookieWriteEncrypted encrypts the cookie's value using AES-GCM and writes it to the HTTP response writer.
// The cookie name is authenticated along with its value.
// It returns an error if encryption fails or the cookie exceeds the maximum allowed length.
func CookieWriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error { func CookieWriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
// Create a new AES cipher block from the secret key. // Create a new AES cipher block from the secret key.
block, err := aes.NewCipher(secretKey) block, err := aes.NewCipher(secretKey)
@ -213,6 +247,8 @@ func CookieWriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey [
return CookieWrite(w, cookie) return CookieWrite(w, cookie)
} }
// CookieReadEncrypted reads an encrypted cookie, decrypts its value using AES-GCM, and validates its name before returning.
// Returns the plaintext cookie value or an error if decryption or validation fails.
func CookieReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) { func CookieReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) {
// Read the encrypted value from the cookie as normal. // Read the encrypted value from the cookie as normal.
encryptedValue, err := CookieRead(r, name) encryptedValue, err := CookieRead(r, name)

202
core.go
View file

@ -6,6 +6,7 @@
package core package core
import ( import (
"context"
"embed" "embed"
"fmt" "fmt"
"log" "log"
@ -25,6 +26,7 @@ import (
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
) )
var loggr *logger.Logger var loggr *logger.Logger
@ -39,22 +41,34 @@ var basePath string
var runMode string var runMode string
var disableEvents bool = false var disableEvents bool = false
// components_resources holds embedded file system data, typically for templates or other embedded resources.
//
//go:embed all:template //go:embed all:template
var components_resources embed.FS var components_resources embed.FS
// configContainer is a configuration structure that holds request-specific configurations for the application.
type configContainer struct { type configContainer struct {
Request RequestConfig Request RequestConfig
} }
// App is a struct representing the main application container and its core components.
type App struct { type App struct {
t int // for tracking hooks t int // for tracking hooks (deprecated - maintained for backward compatibility)
chain *chain chain *chain
hooks *Hooks hooks *Hooks
Config *configContainer Config *configContainer
} }
// requestContext holds request-specific chain execution state
type requestContext struct {
chainIndex int
chainNodes []interface{}
}
// app is a global instance of the App structure used to manage application configuration and lifecycle.
var app *App var app *App
// New initializes and returns a new instance of the App structure with default configurations and chain.
func New() *App { func New() *App {
app = &App{ app = &App{
chain: &chain{}, chain: &chain{},
@ -66,24 +80,29 @@ func New() *App {
return app return app
} }
// ResolveApp returns the global instance of the App, providing access to its methods and configuration components.
func ResolveApp() *App { func ResolveApp() *App {
return app return app
} }
// SetLogsDriver sets the logging driver to be used by the application.
func (app *App) SetLogsDriver(d logger.LogsDriver) { func (app *App) SetLogsDriver(d logger.LogsDriver) {
logsDriver = &d logsDriver = &d
} }
// Bootstrap initializes the application by setting up the logger, router, and events manager.
func (app *App) Bootstrap() { func (app *App) Bootstrap() {
loggr = logger.NewLogger(*logsDriver) loggr = logger.NewLogger(*logsDriver)
NewRouter() NewRouter()
NewEventsManager() NewEventsManager()
} }
// RegisterTemplates initializes the application template system by embedding provided templates for rendering views.
func (app *App) RegisterTemplates(templates_resources embed.FS) { func (app *App) RegisterTemplates(templates_resources embed.FS) {
NewTemplates(components_resources, templates_resources) NewTemplates(components_resources, templates_resources)
} }
// Run initializes the application's router, configures HTTPS if enabled, and starts the HTTP/HTTPS server.
func (app *App) Run(router *httprouter.Router) { func (app *App) Run(router *httprouter.Router) {
portNumber := os.Getenv("App_HTTP_PORT") portNumber := os.Getenv("App_HTTP_PORT")
if portNumber == "" { if portNumber == "" {
@ -93,7 +112,7 @@ func (app *App) Run(router *httprouter.Router) {
// check if template engine is enable // check if template engine is enable
TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE") TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE")
if TemplateEnableStr == "" { if "" == TemplateEnableStr {
TemplateEnableStr = "false" TemplateEnableStr = "false"
} }
TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr) TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr)
@ -102,13 +121,33 @@ func (app *App) Run(router *httprouter.Router) {
router.ServeFiles("/public/*filepath", http.Dir("storage/public")) router.ServeFiles("/public/*filepath", http.Dir("storage/public"))
} }
// check if app engine is enable
AppEnableStr := os.Getenv("APP_ENABLE")
if "" == AppEnableStr {
AppEnableStr = "false"
}
AppEnable, _ := strconv.ParseBool(AppEnableStr)
if AppEnable {
router.ServeFiles("/app/*filepath", http.Dir("storage/app"))
}
// check if app engine is enable
CDNEnableStr := os.Getenv("CDN_ENABLE")
if "" == CDNEnableStr {
CDNEnableStr = "false"
}
CDNEnable, _ := strconv.ParseBool(CDNEnableStr)
if CDNEnable {
router.ServeFiles("/cdn/*filepath", http.Dir("storage/cdn"))
}
useHttpsStr := os.Getenv("App_USE_HTTPS") useHttpsStr := os.Getenv("App_USE_HTTPS")
if useHttpsStr == "" { if useHttpsStr == "" {
useHttpsStr = "false" useHttpsStr = "false"
} }
useHttps, _ := strconv.ParseBool(useHttpsStr) useHttps, _ := strconv.ParseBool(useHttpsStr)
if runMode == "dev" { if "dev" == runMode {
fmt.Printf("Welcome to Goffee\n") fmt.Printf("Welcome to Goffee\n")
if useHttps { if useHttps {
fmt.Printf("Listening on https \nWaiting for requests...\n") fmt.Printf("Listening on https \nWaiting for requests...\n")
@ -156,6 +195,9 @@ func (app *App) Run(router *httprouter.Router) {
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", portNumber), router)) log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", portNumber), router))
} }
// RegisterRoutes sets up and registers HTTP routes and their handlers to the provided router, enabling request processing.
// It assigns appropriate methods, controllers, and hooks, while handling core services, errors, and fallback behaviors.
// Returns the configured router instance.
func (app *App) RegisterRoutes(routes []Route, router *httprouter.Router) *httprouter.Router { func (app *App) RegisterRoutes(routes []Route, router *httprouter.Router) *httprouter.Router {
router.PanicHandler = panicHandler router.PanicHandler = panicHandler
router.NotFound = notFoundHandler{} router.NotFound = notFoundHandler{}
@ -194,12 +236,28 @@ func (app *App) RegisterRoutes(routes []Route, router *httprouter.Router) *httpr
return router return router
} }
// makeHTTPRouterHandlerFunc creates an httprouter.Handle that handles HTTP requests with the specified controller and hooks.
func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Handle { func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Create request-specific chain state
reqCtx := &requestContext{
chainIndex: 0,
}
// Prepare the chain nodes for this request
mw := app.hooks.GetHooks()
for _, v := range mw {
reqCtx.chainNodes = append(reqCtx.chainNodes, v)
}
rhs := app.combHandlers(h, ms)
for _, v := range rhs {
reqCtx.chainNodes = append(reqCtx.chainNodes, v)
}
// Store chain state in request context
ctx := &Context{ ctx := &Context{
Request: &Request{ Request: &Request{
httpRequest: r, httpRequest: r.WithContext(context.WithValue(r.Context(), "goffeeChain", reqCtx)),
httpPathParams: ps, httpPathParams: ps,
}, },
Response: &Response{ Response: &Response{
@ -219,18 +277,24 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha
GetMailer: resolveMailer(), GetMailer: resolveMailer(),
GetEventsManager: resolveEventsManager(), GetEventsManager: resolveEventsManager(),
GetLogger: resolveLogger(), GetLogger: resolveLogger(),
GetSession: getSession(),
} }
ctx.prepare(ctx) ctx.prepare(ctx)
rhs := app.combHandlers(h, ms)
app.prepareChain(rhs) // Execute the first handler in the chain
app.t = 0 if len(reqCtx.chainNodes) > 0 {
app.chain.execute(ctx) n := reqCtx.chainNodes[0]
if f, ok := n.(Hook); ok {
f(ctx)
} else if ff, ok := n.(Controller); ok {
ff(ctx)
}
}
for _, header := range ctx.Response.headers { for _, header := range ctx.Response.headers {
w.Header().Add(header.key, header.val) w.Header().Add(header.key, header.val)
} }
logger.CloseLogsFile()
var ct string var ct string
if ctx.Response.overrideContentType != "" { if ctx.Response.overrideContentType != "" {
ct = ctx.Response.overrideContentType ct = ctx.Response.overrideContentType
@ -245,7 +309,11 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha
w.WriteHeader(ctx.Response.statusCode) w.WriteHeader(ctx.Response.statusCode)
} }
if ctx.Response.redirectTo != "" { if ctx.Response.redirectTo != "" {
http.Redirect(w, r, ctx.Response.redirectTo, http.StatusPermanentRedirect) statusCode := ctx.Response.redirectStatusCode
if statusCode == 0 {
statusCode = http.StatusTemporaryRedirect // default to 307
}
http.Redirect(w, r, ctx.Response.redirectTo, statusCode)
} else { } else {
w.Write(ctx.Response.body) w.Write(ctx.Response.body)
} }
@ -260,27 +328,33 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha
} }
} }
// notFoundHandler is a type that implements the http.Handler interface to handle HTTP 404 Not Found errors.
type notFoundHandler struct{} type notFoundHandler struct{}
// methodNotAllowed is a type that implements http.Handler to handle HTTP 405 Method Not Allowed errors.
type methodNotAllowed struct{} type methodNotAllowed struct{}
// ServeHTTP handles HTTP requests by responding with a 404 status code and a JSON-formatted "Not Found" message.
func (n notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (n notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
res := "{\"message\": \"Not Found\"}" res := "{\"message\": \"Not Found\"}"
loggr.Error("Not Found") loggr.Error("Not Found")
loggr.Error(debug.Stack()) //loggr.Error(debug.Stack())
w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON) w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON)
w.Write([]byte(res)) w.Write([]byte(res))
} }
// ServeHTTP handles HTTP requests with method not allowed status and logs the occurrence and stack trace.
func (n methodNotAllowed) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (n methodNotAllowed) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
res := "{\"message\": \"Method not allowed\"}" res := "{\"message\": \"Method not allowed\"}"
loggr.Error("Method not allowed") loggr.Error("Method not allowed")
loggr.Error(debug.Stack()) //loggr.Error(debug.Stack())
w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON) w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON)
w.Write([]byte(res)) w.Write([]byte(res))
} }
// panicHandler handles panics during HTTP request processing, logs errors, and formats responses based on environment settings.
var panicHandler = func(w http.ResponseWriter, r *http.Request, e interface{}) { var panicHandler = func(w http.ResponseWriter, r *http.Request, e interface{}) {
isDebugModeStr := os.Getenv("APP_DEBUG_MODE") isDebugModeStr := os.Getenv("APP_DEBUG_MODE")
isDebugMode, err := strconv.ParseBool(isDebugModeStr) isDebugMode, err := strconv.ParseBool(isDebugModeStr)
@ -303,7 +377,7 @@ var panicHandler = func(w http.ResponseWriter, r *http.Request, e interface{}) {
shrtMsg := fmt.Sprintf("%v", e) shrtMsg := fmt.Sprintf("%v", e)
loggr.Error(shrtMsg) loggr.Error(shrtMsg)
fmt.Println(shrtMsg) fmt.Println(shrtMsg)
loggr.Error(string(debug.Stack())) //loggr.Error(string(debug.Stack()))
var res string var res string
if env.GetVarOtherwiseDefault("APP_ENV", "local") == PRODUCTION { if env.GetVarOtherwiseDefault("APP_ENV", "local") == PRODUCTION {
res = "{\"message\": \"internal error\"}" res = "{\"message\": \"internal error\"}"
@ -315,34 +389,53 @@ var panicHandler = func(w http.ResponseWriter, r *http.Request, e interface{}) {
w.Write([]byte(res)) w.Write([]byte(res))
} }
// UseHook registers a Hook middleware function by attaching it to the global Hooks instance.
func UseHook(mw Hook) { func UseHook(mw Hook) {
ResolveHooks().Attach(mw) ResolveHooks().Attach(mw)
} }
// Next advances to the next middleware or controller in the chain and invokes it with the given context if available.
func (app *App) Next(c *Context) { func (app *App) Next(c *Context) {
app.t = app.t + 1 // Get request-specific chain state from context
n := app.chain.getByIndex(app.t) if reqCtx, ok := c.Request.httpRequest.Context().Value("goffeeChain").(*requestContext); ok {
if n != nil { reqCtx.chainIndex++
f, ok := n.(Hook) if reqCtx.chainIndex < len(reqCtx.chainNodes) {
if ok { n := reqCtx.chainNodes[reqCtx.chainIndex]
f(c) if f, ok := n.(Hook); ok {
} else { f(c)
ff, ok := n.(Controller) } else if ff, ok := n.(Controller); ok {
if ok {
ff(c) ff(c)
} }
} }
} else {
// Fallback to old behavior (not thread-safe)
app.t = app.t + 1
n := app.chain.getByIndex(app.t)
if n != nil {
f, ok := n.(Hook)
if ok {
f(c)
} else {
ff, ok := n.(Controller)
if ok {
ff(c)
}
}
}
} }
} }
// chain represents a sequence of nodes, where each node is an interface, used for executing a series of operations.
type chain struct { type chain struct {
nodes []interface{} nodes []interface{}
} }
// reset clears all nodes in the chain, resetting it to an empty state.
func (cn *chain) reset() { func (cn *chain) reset() {
cn.nodes = []interface{}{} cn.nodes = []interface{}{}
} }
// getByIndex retrieves an element from the chain's nodes slice by its index. Returns nil if the index is out of range.
func (c *chain) getByIndex(i int) interface{} { func (c *chain) getByIndex(i int) interface{} {
for k := range c.nodes { for k := range c.nodes {
if k == i { if k == i {
@ -353,6 +446,7 @@ func (c *chain) getByIndex(i int) interface{} {
return nil return nil
} }
// prepareChain appends all hooks from the provided list and the application's Hooks to the chain's nodes.
func (app *App) prepareChain(hs []interface{}) { func (app *App) prepareChain(hs []interface{}) {
mw := app.hooks.GetHooks() mw := app.hooks.GetHooks()
for _, v := range mw { for _, v := range mw {
@ -363,6 +457,18 @@ func (app *App) prepareChain(hs []interface{}) {
} }
} }
// prepareRequestChain prepares a request-specific chain to avoid race conditions
func (app *App) prepareRequestChain(requestChain *chain, hs []interface{}) {
mw := app.hooks.GetHooks()
for _, v := range mw {
requestChain.nodes = append(requestChain.nodes, v)
}
for _, v := range hs {
requestChain.nodes = append(requestChain.nodes, v)
}
}
// execute executes the first node in the chain, invoking it as either a Hook or Controller if applicable.
func (cn *chain) execute(ctx *Context) { func (cn *chain) execute(ctx *Context) {
i := cn.getByIndex(0) i := cn.getByIndex(0)
if i != nil { if i != nil {
@ -378,6 +484,7 @@ func (cn *chain) execute(ctx *Context) {
} }
} }
// combHandlers combines a controller and a slice of hooks into a single slice of interfaces in reversed order.
func (app *App) combHandlers(h Controller, mw []Hook) []interface{} { func (app *App) combHandlers(h Controller, mw []Hook) []interface{} {
var rev []interface{} var rev []interface{}
for _, k := range mw { for _, k := range mw {
@ -387,6 +494,8 @@ func (app *App) combHandlers(h Controller, mw []Hook) []interface{} {
return rev return rev
} }
// getGormFunc returns a function that provides a *gorm.DB instance, ensuring gorm is enabled in the configuration.
// If gorm is not enabled, it panics with an appropriate message.
func getGormFunc() func() *gorm.DB { func getGormFunc() func() *gorm.DB {
f := func() *gorm.DB { f := func() *gorm.DB {
if !gormC.EnableGorm { if !gormC.EnableGorm {
@ -397,6 +506,9 @@ func getGormFunc() func() *gorm.DB {
return f return f
} }
// NewGorm initializes and returns a new gorm.DB instance based on the selected database driver specified in environment variables.
// Supported drivers include MySQL, PostgreSQL, and SQLite.
// Panics if the database driver is not specified or if a connection error occurs.
func NewGorm() *gorm.DB { func NewGorm() *gorm.DB {
var err error var err error
switch os.Getenv("DB_DRIVER") { switch os.Getenv("DB_DRIVER") {
@ -411,16 +523,22 @@ func NewGorm() *gorm.DB {
if err != nil { if err != nil {
panic(fmt.Sprintf("error locating sqlite file: %v", err.Error())) 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: default:
panic("database driver not selected") panic("database driver not selected")
} }
if gormC.EnableGorm && err != nil { 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)) 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 return db
} }
// ResolveGorm initializes and returns a singleton instance of *gorm.DB, creating it if it doesn't already exist.
func ResolveGorm() *gorm.DB { func ResolveGorm() *gorm.DB {
if db != nil { if db != nil {
return db return db
@ -429,6 +547,8 @@ func ResolveGorm() *gorm.DB {
return db return db
} }
// resolveCache returns a function that provides a *Cache instance when invoked.
// Panics if caching is not enabled in the configuration.
func resolveCache() func() *Cache { func resolveCache() func() *Cache {
f := func() *Cache { f := func() *Cache {
if !cacheC.EnableCache { if !cacheC.EnableCache {
@ -439,6 +559,8 @@ func resolveCache() func() *Cache {
return f return f
} }
// postgresConnect establishes a connection to a PostgreSQL database using environment variables for configuration.
// It returns a pointer to a gorm.DB instance or an error if the connection fails.
func postgresConnect() (*gorm.DB, error) { func postgresConnect() (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%v user=%v password=%v dbname=%v port=%v sslmode=%v TimeZone=%v", dsn := fmt.Sprintf("host=%v user=%v password=%v dbname=%v port=%v sslmode=%v TimeZone=%v",
os.Getenv("POSTGRES_HOST"), os.Getenv("POSTGRES_HOST"),
@ -449,9 +571,13 @@ func postgresConnect() (*gorm.DB, error) {
os.Getenv("POSTGRES_SSL_MODE"), os.Getenv("POSTGRES_SSL_MODE"),
os.Getenv("POSTGRES_TIMEZONE"), 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.
// Returns a *gorm.DB instance on success or an error if the connection fails.
func mysqlConnect() (*gorm.DB, error) { func mysqlConnect() (*gorm.DB, error) {
dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=%v&parseTime=True&loc=Local", dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=%v&parseTime=True&loc=Local",
os.Getenv("MYSQL_USERNAME"), os.Getenv("MYSQL_USERNAME"),
@ -468,9 +594,14 @@ func mysqlConnect() (*gorm.DB, error) {
DontSupportRenameIndex: true, // drop & create when rename index, rename index not supported before MySQL 5.7, MariaDB 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 DontSupportRenameColumn: true, // `change` when rename column, rename column not supported before MySQL 8, MariaDB
SkipInitializeWithVersion: false, // auto configure based on currently MySQL version 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.
// The JWT instance is created using a secret signing key and a lifespan derived from the environment.
// Panics if the signing key is not set or if the lifespan cannot be parsed.
func getJWT() func() *JWT { func getJWT() func() *JWT {
f := func() *JWT { f := func() *JWT {
secret := os.Getenv("JWT_SECRET") secret := os.Getenv("JWT_SECRET")
@ -494,6 +625,7 @@ func getJWT() func() *JWT {
return f return f
} }
// getValidator returns a closure that instantiates and provides a new instance of Validator.
func getValidator() func() *Validator { func getValidator() func() *Validator {
f := func() *Validator { f := func() *Validator {
return &Validator{} return &Validator{}
@ -501,6 +633,7 @@ func getValidator() func() *Validator {
return f return f
} }
// resloveHashing returns a function that initializes and provides a pointer to a new instance of the Hashing struct.
func resloveHashing() func() *Hashing { func resloveHashing() func() *Hashing {
f := func() *Hashing { f := func() *Hashing {
return &Hashing{} return &Hashing{}
@ -508,6 +641,7 @@ func resloveHashing() func() *Hashing {
return f return f
} }
// resolveMailer initializes and returns a function that provides a singleton Mailer instance based on the configured driver.
func resolveMailer() func() *Mailer { func resolveMailer() func() *Mailer {
f := func() *Mailer { f := func() *Mailer {
if mailer != nil { if mailer != nil {
@ -536,6 +670,7 @@ func resolveMailer() func() *Mailer {
return f return f
} }
// resolveEventsManager creates and returns a function that resolves the singleton instance of EventsManager.
func resolveEventsManager() func() *EventsManager { func resolveEventsManager() func() *EventsManager {
f := func() *EventsManager { f := func() *EventsManager {
return ResolveEventsManager() return ResolveEventsManager()
@ -543,6 +678,7 @@ func resolveEventsManager() func() *EventsManager {
return f return f
} }
// resolveLogger returns a function that provides access to the initialized logger instance when invoked.
func resolveLogger() func() *logger.Logger { func resolveLogger() func() *logger.Logger {
f := func() *logger.Logger { f := func() *logger.Logger {
return loggr return loggr
@ -550,6 +686,15 @@ func resolveLogger() func() *logger.Logger {
return f 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) { func (app *App) MakeDirs(dirs ...string) {
o := syscall.Umask(0) o := syscall.Umask(0)
defer syscall.Umask(o) defer syscall.Umask(o)
@ -558,30 +703,37 @@ func (app *App) MakeDirs(dirs ...string) {
} }
} }
// SetRequestConfig sets the configuration for the request in the application instance.
func (app *App) SetRequestConfig(r RequestConfig) { func (app *App) SetRequestConfig(r RequestConfig) {
requestC = r requestC = r
} }
// SetGormConfig sets the GormConfig for the application, overriding the current configuration with the provided value.
func (app *App) SetGormConfig(g GormConfig) { func (app *App) SetGormConfig(g GormConfig) {
gormC = g gormC = g
} }
// SetCacheConfig sets the cache configuration for the application using the provided CacheConfig parameter.
func (app *App) SetCacheConfig(c CacheConfig) { func (app *App) SetCacheConfig(c CacheConfig) {
cacheC = c cacheC = c
} }
// SetBasePath sets the base path for the application, which is used as a prefix for file and directory operations.
func (app *App) SetBasePath(path string) { func (app *App) SetBasePath(path string) {
basePath = path basePath = path
} }
// SetRunMode sets the application run mode to the specified value.
func (app *App) SetRunMode(runmode string) { func (app *App) SetRunMode(runmode string) {
runMode = runmode runMode = runmode
} }
// DisableEvents sets the global variable `disableEvents` to true, disabling the handling or triggering of events.
func DisableEvents() { func DisableEvents() {
disableEvents = true disableEvents = true
} }
// EnableEvents sets the global variable `disableEvents` to false, effectively enabling event handling.
func EnableEvents() { func EnableEvents() {
disableEvents = false disableEvents = false
} }

60
go.mod
View file

@ -4,49 +4,61 @@ replace git.smarteching.com/goffee/core/logger => ./logger
replace git.smarteching.com/goffee/core/env => ./env replace git.smarteching.com/goffee/core/env => ./env
go 1.23.1 go 1.25.0
require ( require (
git.smarteching.com/zeni/go-chart/v2 v2.1.4 git.smarteching.com/zeni/go-chart/v2 v2.1.4
git.smarteching.com/zeni/go-charts/v2 v2.6.11
github.com/brianvoe/gofakeit/v6 v6.21.0 github.com/brianvoe/gofakeit/v6 v6.21.0
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.3.0 github.com/google/uuid v1.6.0
github.com/harranali/mailing v1.2.0 github.com/harranali/mailing v1.2.0
github.com/hibiken/asynq v0.26.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/redis/go-redis/v9 v9.0.5 github.com/redis/go-redis/v9 v9.21.0
golang.org/x/crypto v0.11.0 golang.org/x/crypto v0.53.0
gorm.io/driver/mysql v1.5.1 golang.org/x/text v0.38.0
gorm.io/driver/postgres v1.5.2 gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.5.2 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.2 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/SparkPost/gosparkpost v0.2.0 // indirect github.com/SparkPost/gosparkpost v0.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/go-chi/chi/v5 v5.0.8 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-chi/chi/v5 v5.3.0 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.3.1 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/json-iterator/go v1.1.10 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mailgun/mailgun-go/v4 v4.10.0 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mailgun/errors v0.6.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/mailgun/mailgun-go/v4 v4.23.0 // indirect
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/sendgrid/sendgrid-go v3.12.0+incompatible // indirect github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
golang.org/x/image v0.21.0 // indirect github.com/spf13/cast v1.10.0 // indirect
golang.org/x/net v0.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )
require ( require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/go-ozzo/ozzo-validation v3.6.0+incompatible
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect

174
go.sum
View file

@ -1,53 +1,63 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q= git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ= git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
git.smarteching.com/zeni/go-charts/v2 v2.6.11 h1:9udzlv3uxGXszpplfkL5IaTUrgkNj++KwhbaN1vVEqI=
git.smarteching.com/zeni/go-charts/v2 v2.6.11/go.mod h1:3OpRPSXg7Qx4zcgsmwsC9ZFB9/wAkGSbnXf1wIbHYCg=
github.com/SparkPost/gosparkpost v0.2.0 h1:yzhHQT7cE+rqzd5tANNC74j+2x3lrPznqPJrxC1yR8s= github.com/SparkPost/gosparkpost v0.2.0 h1:yzhHQT7cE+rqzd5tANNC74j+2x3lrPznqPJrxC1yR8s=
github.com/SparkPost/gosparkpost v0.2.0/go.mod h1:S9WKcGeou7cbPpx0kTIgo8Q69WZvUmVeVzbD+djalJ4= github.com/SparkPost/gosparkpost v0.2.0/go.mod h1:S9WKcGeou7cbPpx0kTIgo8Q69WZvUmVeVzbD+djalJ4=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/brianvoe/gofakeit/v6 v6.21.0 h1:tNkm9yxEbpuPK8Bx39tT4sSc5i9SUGiciLdNix+VDQY= github.com/brianvoe/gofakeit/v6 v6.21.0 h1:tNkm9yxEbpuPK8Bx39tT4sSc5i9SUGiciLdNix+VDQY=
github.com/brianvoe/gofakeit/v6 v6.21.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/brianvoe/gofakeit/v6 v6.21.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ= github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE=
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/harranali/mailing v1.2.0 h1:ihIyJwB8hyRVcdk+v465wk1PHMrSrgJqo/kMd+gZClY= github.com/harranali/mailing v1.2.0 h1:ihIyJwB8hyRVcdk+v465wk1PHMrSrgJqo/kMd+gZClY=
github.com/harranali/mailing v1.2.0/go.mod h1:4a5N3yG98pZKluMpmcYlTtll7bisvOfGQEMIng3VQk4= github.com/harranali/mailing v1.2.0/go.mod h1:4a5N3yG98pZKluMpmcYlTtll7bisvOfGQEMIng3VQk4=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.8.0/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE= github.com/jhillyerd/enmime v0.8.0/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -56,64 +66,112 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailgun/mailgun-go/v4 v4.10.0 h1:e5LVsxpqjOYRyaOWifrJORoLQZTYDP+g4ljfmf9G2zE= github.com/mailgun/errors v0.5.0 h1:pLQo8uhAdORsjN69mGixSr0pGs46z/BW/FQXd8HG1VM=
github.com/mailgun/mailgun-go/v4 v4.10.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8= github.com/mailgun/errors v0.5.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs=
github.com/mailgun/errors v0.6.0 h1:IWmzIGwXCSN/Q60JT/lXvam3xRAgTUJSX88KwKJ7hss=
github.com/mailgun/errors v0.6.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs=
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sendgrid/sendgrid-go v3.12.0+incompatible h1:/N2vx18Fg1KmQOh6zESc5FJB8pYwt5QFBDflYPh1KVg= github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs=
github.com/sendgrid/sendgrid-go v3.12.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"git.smarteching.com/zeni/go-chart/v2" "git.smarteching.com/zeni/go-chart/v2"
"git.smarteching.com/zeni/go-charts/v2"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
) )
@ -44,7 +45,7 @@ func Graph(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
qtyl := len(labels) qtyl := len(labels)
qtyv := len(values) qtyv := len(values)
// cjeck qty and equal values from url // check qty and equal values from url
if qtyl < 2 { if qtyl < 2 {
fmt.Fprintf(w, "Missing captions in pie") fmt.Fprintf(w, "Missing captions in pie")
return return
@ -85,9 +86,9 @@ func Graph(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
qtyl := len(labels) qtyl := len(labels)
qtyv := len(values) qtyv := len(values)
// cjeck qty and equal values from url // check qty and equal values from url
if qtyl < 2 { if qtyl < 2 {
fmt.Fprintf(w, "Missing captions in pie") fmt.Fprintf(w, "Missing captions in bar")
return return
} }
if qtyv != qtyl { if qtyv != qtyl {
@ -116,6 +117,83 @@ func Graph(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
fmt.Printf("Error rendering pie chart: %v\n", err) fmt.Printf("Error rendering pie chart: %v\n", err)
} }
// case multiple graph bar
case "mbar":
queryValues := r.URL.Query()
labels := strings.Split(queryValues.Get("l"), "|")
values := strings.Split(queryValues.Get("v"), "|")
options := strings.Split(queryValues.Get("o"), "|")
qtyl := len(labels)
qtyv := len(values)
qtyo := len(options)
// check qty and equal values from url
if qtyl < 2 {
fmt.Fprintf(w, "Missing captions in bar")
return
}
if qtyv < 2 {
fmt.Fprintf(w, "Missing values in bar")
return
}
if qtyo < 2 {
fmt.Fprintf(w, "Missing options in bar")
return
}
valuest := [][]float64{
{
2.0,
4.9,
7.0,
23.2,
25.6,
76.7,
135.6,
162.2,
32.6,
},
{
2.6,
5.9,
9.0,
26.4,
28.7,
70.7,
175.6,
182.2,
48.7,
},
}
p, err := charts.BarRender(
valuest,
charts.XAxisDataOptionFunc([]string{
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
}),
charts.LegendLabelsOptionFunc(labels, charts.PositionRight),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
w.Header().Set("Content-Type", "image/png")
w.Write(buf)
if err != nil {
fmt.Printf("Error rendering pie chart: %v\n", err)
}
default: default:
fmt.Fprintf(w, "Unknown graph %s!\n", kindg) fmt.Fprintf(w, "Unknown graph %s!\n", kindg)
} }

View file

@ -1,4 +1,4 @@
// Copyright 2021 Harran Ali <harran.m@gmail.com>. All rights reserved. // Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
// Use of this source code is governed by MIT-style // Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -8,16 +8,68 @@ import (
"io" "io"
"log" "log"
"os" "os"
"strings"
) )
// logs file // logs file
var logsFile *os.File var logsFile *os.File
// ANSI color codes for terminal output
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorCyan = "\033[36m"
)
// Level represents a log severity level.
type Level int
const (
DEBUG Level = iota
INFO
WARNING
ERROR
)
// String returns the string representation of a log level.
func (l Level) String() string {
switch l {
case DEBUG:
return "debug"
case INFO:
return "info"
case WARNING:
return "warning"
case ERROR:
return "error"
default:
return "unknown"
}
}
// coloredWriter wraps an io.Writer and replaces plain log prefixes with ANSI-colored ones
// in the output stream. The original bytes pass through unmodified to any additional writers.
type coloredWriter struct {
writer io.Writer
plain string
colored string
}
func (w *coloredWriter) Write(p []byte) (n int, err error) {
// Replace the plain prefix with the colored version in the output
colored := strings.Replace(string(p), w.plain, w.colored, 1)
return w.writer.Write([]byte(colored))
}
type Logger struct { type Logger struct {
minLevel Level
infoLogger *log.Logger infoLogger *log.Logger
warningLogger *log.Logger warningLogger *log.Logger
errorLogger *log.Logger errorLogger *log.Logger
debugLogger *log.Logger debugLogger *log.Logger
file *os.File
} }
var l *Logger var l *Logger
@ -40,9 +92,36 @@ func (f LogFileDriver) GetTarget() interface{} {
return f.FilePath return f.FilePath
} }
// levelPrefixes returns the plain and colored (with ANSI escapes) prefix strings for a given level.
func levelPrefixes(level Level) (plain, colored string) {
switch level {
case INFO:
return "info: ", colorBlue + "INFO" + colorReset + ": "
case DEBUG:
return "debug: ", colorCyan + "DEBUG" + colorReset + ": "
case WARNING:
return "warning: ", colorYellow + "WARNING" + colorReset + ": "
case ERROR:
return "error: ", colorRed + "ERROR" + colorReset + ": "
default:
return "info: ", "INFO: "
}
}
// NewLogger creates a new Logger with the given driver and a minimum level of DEBUG (all levels logged).
func NewLogger(driver LogsDriver) *Logger { func NewLogger(driver LogsDriver) *Logger {
stdoutEnabled := os.Getenv("LOG_STDOUT_ENABLE") == "true"
return NewLoggerWithStdout(driver, DEBUG, stdoutEnabled)
}
// NewLoggerWithStdout creates a new Logger with the given driver, minimum log level,
// and optionally writes to stdout in addition to the target file.
// When stdoutEnabled is true, log messages are written to both the target and stdout.
// ANSI color codes are applied to log prefixes in terminal output only.
func NewLoggerWithStdout(driver LogsDriver, minLevel Level, stdoutEnabled bool) *Logger {
if driver.GetTarget() == nil { if driver.GetTarget() == nil {
l = &Logger{ l = &Logger{
minLevel: minLevel,
infoLogger: log.New(io.Discard, "info: ", log.LstdFlags), infoLogger: log.New(io.Discard, "info: ", log.LstdFlags),
warningLogger: log.New(io.Discard, "warning: ", log.LstdFlags), warningLogger: log.New(io.Discard, "warning: ", log.LstdFlags),
errorLogger: log.New(io.Discard, "error: ", log.LstdFlags), errorLogger: log.New(io.Discard, "error: ", log.LstdFlags),
@ -54,41 +133,103 @@ func NewLogger(driver LogsDriver) *Logger {
if !ok { if !ok {
panic("something wrong with the file path") panic("something wrong with the file path")
} }
logsFile, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0644) var err error
logsFile, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0644)
if err != nil { if err != nil {
panic(err) panic(err)
} }
l = &Logger{
infoLogger: log.New(logsFile, "info: ", log.LstdFlags), if stdoutEnabled {
warningLogger: log.New(logsFile, "warning: ", log.LstdFlags), // Build each logger with a MultiWriter: the file gets the original plain output,
errorLogger: log.New(logsFile, "error: ", log.LstdFlags), // stdout gets the output with ANSI-colored prefixes.
debugLogger: log.New(logsFile, "debug: ", log.LstdFlags), infoPlain, infoColored := levelPrefixes(INFO)
debugPlain, debugColored := levelPrefixes(DEBUG)
warnPlain, warnColored := levelPrefixes(WARNING)
errPlain, errColored := levelPrefixes(ERROR)
l = &Logger{
minLevel: minLevel,
infoLogger: log.New(
io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: infoPlain, colored: infoColored}),
infoPlain, log.LstdFlags,
),
warningLogger: log.New(
io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: warnPlain, colored: warnColored}),
warnPlain, log.LstdFlags,
),
errorLogger: log.New(
io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: errPlain, colored: errColored}),
errPlain, log.LstdFlags,
),
debugLogger: log.New(
io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: debugPlain, colored: debugColored}),
debugPlain, log.LstdFlags,
),
file: logsFile,
}
} else {
// No stdout: all output goes to file with plain prefixes
l = &Logger{
minLevel: minLevel,
infoLogger: log.New(logsFile, "info: ", log.LstdFlags),
warningLogger: log.New(logsFile, "warning: ", log.LstdFlags),
errorLogger: log.New(logsFile, "error: ", log.LstdFlags),
debugLogger: log.New(logsFile, "debug: ", log.LstdFlags),
file: logsFile,
}
} }
return l return l
} }
// NewLoggerWithLevel creates a new Logger with the given driver and minimum log level.
// Messages below the minimum level will be discarded. Stdout output is disabled by default.
func NewLoggerWithLevel(driver LogsDriver, minLevel Level) *Logger {
stdoutEnabled := os.Getenv("LOG_STDOUT_ENABLE") == "true"
return NewLoggerWithStdout(driver, minLevel, stdoutEnabled)
}
// SetLevel sets the minimum log level for the logger.
// Messages below this level will be discarded.
func (l *Logger) SetLevel(level Level) {
l.minLevel = level
}
// GetLevel returns the current minimum log level.
func (l *Logger) GetLevel() Level {
return l.minLevel
}
func ResolveLogger() *Logger { func ResolveLogger() *Logger {
return l return l
} }
func (l *Logger) Info(msg interface{}) { func (l *Logger) Info(msg interface{}) {
l.infoLogger.Println(msg) if l.minLevel <= INFO {
l.infoLogger.Println(msg)
}
} }
func (l *Logger) Debug(msg interface{}) { func (l *Logger) Debug(msg interface{}) {
l.debugLogger.Println(msg) if l.minLevel <= DEBUG {
l.debugLogger.Println(msg)
}
} }
func (l *Logger) Warning(msg interface{}) { func (l *Logger) Warning(msg interface{}) {
l.warningLogger.Println(msg) if l.minLevel <= WARNING {
l.warningLogger.Println(msg)
}
} }
func (l *Logger) Error(msg interface{}) { func (l *Logger) Error(msg interface{}) {
l.errorLogger.Println(msg) if l.minLevel <= ERROR {
} l.errorLogger.Println(msg)
func CloseLogsFile() {
if logsFile != nil {
defer logsFile.Close()
} }
} }
func (l *Logger) Close() error {
if l.file != nil {
return l.file.Close()
}
return nil
}

View file

@ -61,7 +61,7 @@ func TestInfo(t *testing.T) {
t.Error("error testing info") t.Error("error testing info")
} }
t.Cleanup(func() { t.Cleanup(func() {
CloseLogsFile() l.Close()
}) })
} }
@ -84,7 +84,7 @@ func TestWarning(t *testing.T) {
t.Error("failed testing warning") t.Error("failed testing warning")
} }
t.Cleanup(func() { t.Cleanup(func() {
CloseLogsFile() l.Close()
}) })
} }
@ -106,7 +106,7 @@ func TestDebug(t *testing.T) {
t.Error("error testing debug") t.Error("error testing debug")
} }
t.Cleanup(func() { t.Cleanup(func() {
CloseLogsFile() l.Close()
}) })
} }
@ -128,6 +128,173 @@ func TestError(t *testing.T) {
t.Error("failed testing error") t.Error("failed testing error")
} }
t.Cleanup(func() { t.Cleanup(func() {
CloseLogsFile() l.Close()
}) })
} }
func TestLevelFiltering(t *testing.T) {
t.Run("NewLoggerWithLevel_DEBUG", func(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, DEBUG)
l.Debug("debug-msg")
l.Info("info-msg")
l.Warning("warn-msg")
l.Error("err-msg")
l.Close()
b, _ := os.ReadFile(path)
content := string(b)
if !strings.Contains(content, "debug-msg") {
t.Error("expected debug-msg to be logged at DEBUG level")
}
if !strings.Contains(content, "info-msg") {
t.Error("expected info-msg to be logged at DEBUG level")
}
if !strings.Contains(content, "warn-msg") {
t.Error("expected warn-msg to be logged at DEBUG level")
}
if !strings.Contains(content, "err-msg") {
t.Error("expected err-msg to be logged at DEBUG level")
}
})
t.Run("NewLoggerWithLevel_INFO", func(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, INFO)
l.Debug("debug-msg")
l.Info("info-msg")
l.Warning("warn-msg")
l.Error("err-msg")
l.Close()
b, _ := os.ReadFile(path)
content := string(b)
if strings.Contains(content, "debug-msg") {
t.Error("debug-msg should NOT be logged at INFO minimum level")
}
if !strings.Contains(content, "info-msg") {
t.Error("expected info-msg to be logged at INFO level")
}
if !strings.Contains(content, "warn-msg") {
t.Error("expected warn-msg to be logged at INFO level")
}
if !strings.Contains(content, "err-msg") {
t.Error("expected err-msg to be logged at INFO level")
}
})
t.Run("NewLoggerWithLevel_WARNING", func(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, WARNING)
l.Debug("debug-msg")
l.Info("info-msg")
l.Warning("warn-msg")
l.Error("err-msg")
l.Close()
b, _ := os.ReadFile(path)
content := string(b)
if strings.Contains(content, "debug-msg") {
t.Error("debug-msg should NOT be logged at WARNING minimum level")
}
if strings.Contains(content, "info-msg") {
t.Error("info-msg should NOT be logged at WARNING minimum level")
}
if !strings.Contains(content, "warn-msg") {
t.Error("expected warn-msg to be logged at WARNING level")
}
if !strings.Contains(content, "err-msg") {
t.Error("expected err-msg to be logged at WARNING level")
}
})
t.Run("NewLoggerWithLevel_ERROR", func(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, ERROR)
l.Debug("debug-msg")
l.Info("info-msg")
l.Warning("warn-msg")
l.Error("err-msg")
l.Close()
b, _ := os.ReadFile(path)
content := string(b)
if strings.Contains(content, "debug-msg") {
t.Error("debug-msg should NOT be logged at ERROR minimum level")
}
if strings.Contains(content, "info-msg") {
t.Error("info-msg should NOT be logged at ERROR minimum level")
}
if strings.Contains(content, "warn-msg") {
t.Error("warn-msg should NOT be logged at ERROR minimum level")
}
if !strings.Contains(content, "err-msg") {
t.Error("expected err-msg to be logged at ERROR level")
}
})
t.Run("SetLevel_dynamically", func(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLogger(&LogFileDriver{FilePath: path})
// Default is DEBUG, so debug messages are logged
l.Debug("debug-msg-before")
l.SetLevel(WARNING)
l.Debug("debug-msg-after")
l.Info("info-msg-after")
l.Warning("warn-msg-after")
l.Error("err-msg-after")
l.Close()
b, _ := os.ReadFile(path)
content := string(b)
if !strings.Contains(content, "debug-msg-before") {
t.Error("expected debug-msg-before to be logged before level change")
}
if strings.Contains(content, "debug-msg-after") {
t.Error("debug-msg-after should NOT be logged after setting level to WARNING")
}
if strings.Contains(content, "info-msg-after") {
t.Error("info-msg-after should NOT be logged after setting level to WARNING")
}
if !strings.Contains(content, "warn-msg-after") {
t.Error("expected warn-msg-after to be logged at WARNING level")
}
if !strings.Contains(content, "err-msg-after") {
t.Error("expected err-msg-after to be logged at WARNING level")
}
})
}
func TestLevelString(t *testing.T) {
tests := []struct {
level Level
want string
}{
{DEBUG, "debug"},
{INFO, "info"},
{WARNING, "warning"},
{ERROR, "error"},
{Level(99), "unknown"},
}
for _, tt := range tests {
if got := tt.level.String(); got != tt.want {
t.Errorf("Level(%d).String() = %q, want %q", tt.level, got, tt.want)
}
}
}
func TestGetLevel(t *testing.T) {
path := filepath.Join(t.TempDir(), uuid.NewString())
l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, WARNING)
defer l.Close()
if l.GetLevel() != WARNING {
t.Errorf("GetLevel() = %d, want %d", l.GetLevel(), WARNING)
}
l.SetLevel(INFO)
if l.GetLevel() != INFO {
t.Errorf("GetLevel() after SetLevel(INFO) = %d, want %d", l.GetLevel(), INFO)
}
}

62
queue.go Normal file
View file

@ -0,0 +1,62 @@
// Copyright (c) 2025 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 (
"context"
"fmt"
"log"
"os"
"github.com/hibiken/asynq"
)
type Asynqtask func(context.Context, *asynq.Task) error
type Queuemux struct {
themux *asynq.ServeMux
}
func (q *Queuemux) QueueInit() {
q.themux = asynq.NewServeMux()
}
func (q *Queuemux) AddWork(pattern string, work Asynqtask) {
q.themux.HandleFunc(pattern, work)
}
// 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(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: concurrency,
Queues: queues,
},
)
if err := srv.Run(q.themux); err != nil {
log.Fatal(err)
}
}

View file

@ -11,6 +11,7 @@ import (
"net/http" "net/http"
) )
// Response represents an HTTP response to be sent to the client, including headers, body, status code, and metadata.
type Response struct { type Response struct {
headers []header headers []header
body []byte body []byte
@ -19,15 +20,38 @@ type Response struct {
overrideContentType string overrideContentType string
isTerminated bool isTerminated bool
redirectTo string redirectTo string
redirectStatusCode int
HttpResponseWriter http.ResponseWriter HttpResponseWriter http.ResponseWriter
} }
// header represents a single HTTP header with a key-value pair.
type header struct { type header struct {
key string key string
val string val string
} }
// TODO add doc // writes the contents of a buffer to the HTTP response with specified file name and type if not terminated.
func (rs *Response) BufferFile(name string, filetype string, b bytes.Buffer) *Response {
if rs.isTerminated == false {
rs.HttpResponseWriter.Header().Add("Content-Type", filetype)
rs.HttpResponseWriter.Header().Add("Content-Disposition", "attachment; filename="+name)
b.WriteTo(rs.HttpResponseWriter)
}
return rs
}
// writes the contents of a buffer to the HTTP response with specified file name and type if not terminated.
func (rs *Response) BufferInline(name string, filetype string, b bytes.Buffer) *Response {
if rs.isTerminated == false {
rs.HttpResponseWriter.Header().Add("Content-Type", filetype)
b.WriteTo(rs.HttpResponseWriter)
}
return rs
}
// sets the response's content type to HTML and assigns the provided body as the response body if not terminated.
func (rs *Response) Any(body any) *Response { func (rs *Response) Any(body any) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.contentType = CONTENT_TYPE_HTML rs.contentType = CONTENT_TYPE_HTML
@ -37,7 +61,7 @@ func (rs *Response) Any(body any) *Response {
return rs return rs
} }
// TODO add doc // sets the response's content type to JSON and assigns the provided string as the body if the response is not terminated.
func (rs *Response) Json(body string) *Response { func (rs *Response) Json(body string) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.contentType = CONTENT_TYPE_JSON rs.contentType = CONTENT_TYPE_JSON
@ -46,7 +70,7 @@ func (rs *Response) Json(body string) *Response {
return rs return rs
} }
// TODO add doc // sets the response's content type to plain text and assigns the provided string as the body if not terminated.
func (rs *Response) Text(body string) *Response { func (rs *Response) Text(body string) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.contentType = CONTENT_TYPE_TEXT rs.contentType = CONTENT_TYPE_TEXT
@ -55,7 +79,7 @@ func (rs *Response) Text(body string) *Response {
return rs return rs
} }
// TODO add doc // sets the response's content type to HTML and assigns the provided string as the body if the response is not terminated.
func (rs *Response) HTML(body string) *Response { func (rs *Response) HTML(body string) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.contentType = CONTENT_TYPE_HTML rs.contentType = CONTENT_TYPE_HTML
@ -64,7 +88,7 @@ func (rs *Response) HTML(body string) *Response {
return rs return rs
} }
// TODO add doc // renders the specified template with the provided data and writes the result to the HTTP response if not terminated.
func (rs *Response) Template(name string, data interface{}) *Response { func (rs *Response) Template(name string, data interface{}) *Response {
var buffer bytes.Buffer var buffer bytes.Buffer
if rs.isTerminated == false { if rs.isTerminated == false {
@ -73,12 +97,12 @@ func (rs *Response) Template(name string, data interface{}) *Response {
panic(fmt.Sprintf("error executing template: %v", err)) panic(fmt.Sprintf("error executing template: %v", err))
} }
rs.contentType = CONTENT_TYPE_HTML rs.contentType = CONTENT_TYPE_HTML
buffer.WriteTo(rs.HttpResponseWriter) rs.body = buffer.Bytes()
} }
return rs return rs
} }
// TODO add doc // sets the response status code if the response is not yet terminated. Returns the updated Response object.
func (rs *Response) SetStatusCode(code int) *Response { func (rs *Response) SetStatusCode(code int) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.statusCode = code rs.statusCode = code
@ -87,7 +111,7 @@ func (rs *Response) SetStatusCode(code int) *Response {
return rs return rs
} }
// TODO add doc // sets a custom content type for the response if it is not terminated. Returns the updated Response object.
func (rs *Response) SetContentType(c string) *Response { func (rs *Response) SetContentType(c string) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
rs.overrideContentType = c rs.overrideContentType = c
@ -96,7 +120,7 @@ func (rs *Response) SetContentType(c string) *Response {
return rs return rs
} }
// TODO add doc // adds or updates a header to the response if it is not terminated. Returns the updated Response object.
func (rs *Response) SetHeader(key string, val string) *Response { func (rs *Response) SetHeader(key string, val string) *Response {
if rs.isTerminated == false { if rs.isTerminated == false {
h := header{ h := header{
@ -108,17 +132,28 @@ func (rs *Response) SetHeader(key string, val string) *Response {
return rs return rs
} }
// terminates the response processing, preventing any further modifications or actions.
func (rs *Response) ForceSendResponse() { func (rs *Response) ForceSendResponse() {
rs.isTerminated = true rs.isTerminated = true
} }
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() validator := resolveValidator()
v := validator.Validate(map[string]interface{}{ v := validator.Validate(map[string]interface{}{
"url": url, "url": url,
}, map[string]interface{}{ }, map[string]interface{}{
"url": "url", "url": "url",
}) })
if len(use303) > 0 && use303[0] {
rs.redirectStatusCode = http.StatusSeeOther // 303
} else {
rs.redirectStatusCode = http.StatusTemporaryRedirect // 307 (default)
}
if v.Failed() { if v.Failed() {
if url[0:1] != "/" { if url[0:1] != "/" {
rs.redirectTo = "/" + url rs.redirectTo = "/" + url
@ -128,9 +163,11 @@ func (rs *Response) Redirect(url string) *Response {
return rs return rs
} }
rs.redirectTo = url rs.redirectTo = url
return rs return rs
} }
// converts various primitive data types to their string representation or triggers a panic for unsupported types.
func (rs *Response) castBasicVarsToString(data interface{}) string { func (rs *Response) castBasicVarsToString(data interface{}) string {
switch dataType := data.(type) { switch dataType := data.(type) {
case string: case string:
@ -188,6 +225,7 @@ func (rs *Response) castBasicVarsToString(data interface{}) string {
} }
} }
// reinitializes the Response object to its default state, clearing its body, headers, and resetting other properties.
func (rs *Response) reset() { func (rs *Response) reset() {
rs.body = nil rs.body = nil
rs.statusCode = http.StatusOK rs.statusCode = http.StatusOK

View file

@ -5,6 +5,7 @@
package core package core
// Route defines an HTTP route with a method, path, controller function, and optional hooks for customization.
type Route struct { type Route struct {
Method string Method string
Path string Path string
@ -12,12 +13,15 @@ type Route struct {
Hooks []Hook Hooks []Hook
} }
// Router represents a structure for storing and managing routes in the application.
type Router struct { type Router struct {
Routes []Route Routes []Route
} }
// router is a global instance of the Router struct used to manage and resolve application routes.
var router *Router var router *Router
// NewRouter initializes and returns a new Router instance with an empty slice of routes.
func NewRouter() *Router { func NewRouter() *Router {
router = &Router{ router = &Router{
[]Route{}, []Route{},
@ -25,10 +29,12 @@ func NewRouter() *Router {
return router return router
} }
// ResolveRouter returns the singleton instance of the Router. It initializes and manages HTTP routes configuration.
func ResolveRouter() *Router { func ResolveRouter() *Router {
return router return router
} }
// Get registers a GET route with the specified path, controller, and optional hooks, returning the updated Router.
func (r *Router) Get(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Get(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: GET, Method: GET,
@ -39,6 +45,7 @@ func (r *Router) Get(path string, controller Controller, hooks ...Hook) *Router
return r return r
} }
// Post registers a route with the HTTP POST method, associating it with the given path, controller, and optional hooks.
func (r *Router) Post(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Post(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: POST, Method: POST,
@ -49,6 +56,7 @@ func (r *Router) Post(path string, controller Controller, hooks ...Hook) *Router
return r return r
} }
// Delete registers a DELETE HTTP method route with the specified path, controller, and optional hooks.
func (r *Router) Delete(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Delete(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: DELETE, Method: DELETE,
@ -59,6 +67,7 @@ func (r *Router) Delete(path string, controller Controller, hooks ...Hook) *Rout
return r return r
} }
// Patch registers a new route with the HTTP PATCH method, a specified path, a controller, and optional hooks.
func (r *Router) Patch(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Patch(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: PATCH, Method: PATCH,
@ -69,6 +78,7 @@ func (r *Router) Patch(path string, controller Controller, hooks ...Hook) *Route
return r return r
} }
// Put registers a new route with the HTTP PUT method, associating it to the given path, controller, and optional hooks.
func (r *Router) Put(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Put(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: PUT, Method: PUT,
@ -79,6 +89,7 @@ func (r *Router) Put(path string, controller Controller, hooks ...Hook) *Router
return r return r
} }
// Options registers a new route with the OPTIONS HTTP method, a specified path, controller, and optional hooks.
func (r *Router) Options(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Options(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: OPTIONS, Method: OPTIONS,
@ -89,6 +100,7 @@ func (r *Router) Options(path string, controller Controller, hooks ...Hook) *Rou
return r return r
} }
// Head registers a route with the HTTP HEAD method, associates it with a path, controller, and optional hooks.
func (r *Router) Head(path string, controller Controller, hooks ...Hook) *Router { func (r *Router) Head(path string, controller Controller, hooks ...Hook) *Router {
r.Routes = append(r.Routes, Route{ r.Routes = append(r.Routes, Route{
Method: HEAD, Method: HEAD,
@ -99,6 +111,7 @@ func (r *Router) Head(path string, controller Controller, hooks ...Hook) *Router
return r return r
} }
// GetRoutes returns a slice of Route objects currently registered within the Router.
func (r *Router) GetRoutes() []Route { func (r *Router) GetRoutes() []Route {
return r.Routes return r.Routes
} }

157
scheduler.go Normal file
View file

@ -0,0 +1,157 @@
// Copyright (c) 2026 Jose Cely <me@jacs.guru>
// Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file.
package core
import (
"context"
"fmt"
"log"
"time"
"git.smarteching.com/goffee/core/logger"
"git.smarteching.com/goffee/core/scheduler"
)
// Default values for SchedulerConfig
var DefaultSchedulerInterval = 10 * time.Second
var DefaultSchedulerRateLimit = 10
// globalSchedulerMux holds the active Schedulermux instance so controllers
// can access it without requiring a direct import chain.
var globalSchedulerMux *Schedulermux
// Schedulermux wraps the scheduler Registry and manages the lifecycle
// of a simple database-backed task scheduler.
type Schedulermux struct {
registry *scheduler.Registry
store *scheduler.Store
semaphore *scheduler.PersistedSemaphore
sched *scheduler.Scheduler
}
// SchedulerInit initializes the scheduler internals (registry).
// The store must be set separately via SetStore before starting.
// The semaphore is created lazily during RunScheduler once the DB is available.
func (s *Schedulermux) SchedulerInit() {
s.registry = scheduler.NewRegistry()
globalSchedulerMux = s
}
// SetStore sets the store (backed by *gorm.DB) on the scheduler.
// This also runs AutoMigrate for the queue_items and processed_items tables.
func (s *Schedulermux) SetStore(st *scheduler.Store) {
s.store = st
}
// AddWork registers a task handler function for a given task type.
func (s *Schedulermux) AddWork(taskType string, handler scheduler.HandlerFunc) {
s.registry.Register(taskType, handler)
}
// GetStore returns the scheduler store, or nil if not initialized.
func (s *Schedulermux) GetStore() *scheduler.Store {
return s.store
}
// RunScheduler starts the scheduler loop with the given configuration.
// It auto-migrates the scheduler_meta table, recovers the persisted semaphore
// state, creates the in-memory semaphore, and launches the scheduler goroutine.
func (s *Schedulermux) RunScheduler(config SchedulerConfig) {
if s.store == nil {
log.Fatal("scheduler: store is not set — call SetStore before RunScheduler")
}
interval := config.SchedulerInterval
if interval <= 0 {
interval = DefaultSchedulerInterval
}
rateLimit := config.SchedulerRateLimit
if rateLimit < 0 {
rateLimit = DefaultSchedulerRateLimit
}
ctx := context.Background()
// Auto-migrate the scheduler_meta table for semaphore persistence.
_ = s.store.DB().AutoMigrate(&scheduler.SchedulerMeta{})
// Recover the persisted semaphore state (defaults to green).
wasGreen := s.store.GetSemaphore(ctx)
s.semaphore = scheduler.NewPersistedSemaphore(s.store, wasGreen)
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] semaphore recovered: %s", map[bool]string{true: "green", false: "red"}[wasGreen]))
// Recover stuck items from a previous crash before starting.
recovered, err := s.store.RecoverStuck(ctx)
if err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] recover stuck error: %v", err))
} else if recovered > 0 {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] recovered %d stuck task(s) back to pending", recovered))
}
s.sched = scheduler.NewScheduler(
scheduler.SchedulerConfig{
Interval: interval,
RateLimit: rateLimit,
},
s.semaphore,
s.store,
s.registry,
)
s.sched.Start(ctx)
}
// StopScheduler gracefully stops the scheduler loop.
func (s *Schedulermux) StopScheduler() {
if s.sched != nil {
s.sched.Stop()
}
}
// SemaphoreSetGreen allows task execution to proceed.
func (s *Schedulermux) SemaphoreSetGreen() {
if s.semaphore != nil {
s.semaphore.SetGreen()
}
}
// SemaphoreSetRed blocks task execution.
func (s *Schedulermux) SemaphoreSetRed() {
if s.semaphore != nil {
s.semaphore.SetRed()
}
}
// ResolveSchedulerStore returns the store from the global scheduler mux instance.
// Returns nil if the scheduler was never initialized.
func ResolveSchedulerStore() *scheduler.Store {
if globalSchedulerMux == nil {
return nil
}
return globalSchedulerMux.GetStore()
}
// SchedulerSemaphoreSetGreen enables task execution by setting the semaphore to green.
func SchedulerSemaphoreSetGreen() {
if globalSchedulerMux != nil {
globalSchedulerMux.SemaphoreSetGreen()
}
}
// SchedulerSemaphoreSetRed blocks task execution by setting the semaphore to red.
func SchedulerSemaphoreSetRed() {
if globalSchedulerMux != nil {
globalSchedulerMux.SemaphoreSetRed()
}
}
// SchedulerSemaphoreIsGreen returns whether the scheduler semaphore is currently green.
func SchedulerSemaphoreIsGreen() bool {
if globalSchedulerMux == nil || globalSchedulerMux.semaphore == nil {
return false
}
return globalSchedulerMux.semaphore.IsGreen()
}

689
scheduler/scheduler.go Normal file
View file

@ -0,0 +1,689 @@
// Copyright (c) 2026 Jose Cely <me@jacs.guru>
// Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file.
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"git.smarteching.com/goffee/core/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ─── Status types ────────────────────────────────────────────────────────────
// QueueStatus represents the lifecycle state of a queued item.
type QueueStatus string
const (
StatusPending QueueStatus = "pending"
StatusProcessing QueueStatus = "processing" // claimed by a scheduler tick
)
// ProcessedStatus is the final outcome recorded in processed_items.
type ProcessedStatus string
const (
ProcessedOK ProcessedStatus = "ok"
ProcessedError ProcessedStatus = "error"
)
// ─── Models ──────────────────────────────────────────────────────────────────
// QueueItem is a task waiting to be executed.
// Any external program can insert a row into this table to enqueue work.
//
// INSERT INTO queue_items (task_type, payload, priority, max_runs, thread) VALUES (?, ?, ?, ?, ?);
type QueueItem struct {
ID uint `gorm:"primaryKey;autoIncrement"`
TaskType string `gorm:"not null;index"` // e.g. "send_email", "sync_records"
Payload string `gorm:"type:text"` // JSON or plain string, interpreted by the handler
Status QueueStatus `gorm:"not null;default:'pending';index"`
Priority int `gorm:"not null;default:0;index"` // higher = runs first within a tick
MaxRuns int `gorm:"not null;default:1"` // 1 = run once, -1 = repeat indefinitely, N = run N times
Thread int `gorm:"not null;default:0"` // 0 = sequential, 1 = concurrent per type
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName overrides the GORM default.
func (QueueItem) TableName() string { return "queue_items" }
// ProcessedItem is an immutable record of a completed execution.
// Rows are never updated — only inserted.
type ProcessedItem struct {
ID uint `gorm:"primaryKey;autoIncrement"`
QueueItemID uint `gorm:"not null;index"` // FK → queue_items.id (soft ref)
TaskType string `gorm:"not null;index"`
Payload string `gorm:"type:text"`
Status ProcessedStatus `gorm:"not null;index"`
ErrorMsg string `gorm:"type:text"` // empty when Status == "ok"
StartedAt time.Time `gorm:"not null"`
FinishedAt time.Time `gorm:"not null"`
DurationMs int64 `gorm:"not null"` // FinishedAt - StartedAt in ms
CreatedAt time.Time
}
// TableName overrides the GORM default.
func (ProcessedItem) TableName() string { return "processed_items" }
// SchedulerMeta stores key-value metadata for the scheduler, such as the
// semaphore state. This table persists across restarts so the scheduler can
// recover its previous state.
type SchedulerMeta struct {
Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"size:16;not null"`
}
// TableName overrides the GORM default.
func (SchedulerMeta) TableName() string { return "scheduler_meta" }
// ─── Handler ─────────────────────────────────────────────────────────────────
// HandlerFunc processes a single queue item.
// Return a non-nil error to mark the item as processed with error status.
type HandlerFunc func(ctx context.Context, item *QueueItem) error
// Registry maps task types to their handlers.
// Register all handlers before calling Scheduler.Start.
type Registry struct {
handlers map[string]HandlerFunc
}
// NewRegistry creates an empty handler registry.
func NewRegistry() *Registry {
return &Registry{handlers: make(map[string]HandlerFunc)}
}
// Register associates a task type with a handler.
// Panics on duplicate registration to catch misconfiguration at startup.
func (r *Registry) Register(taskType string, h HandlerFunc) {
if _, exists := r.handlers[taskType]; exists {
panic(fmt.Sprintf("scheduler: handler already registered for task type %q", taskType))
}
r.handlers[taskType] = h
}
// get returns the handler for a task type, or a fallback error handler.
func (r *Registry) get(taskType string) HandlerFunc {
if h, ok := r.handlers[taskType]; ok {
return h
}
return func(_ context.Context, item *QueueItem) error {
return fmt.Errorf("no handler registered for task type %q", item.TaskType)
}
}
// ─── Store ───────────────────────────────────────────────────────────────────
// Store handles all database operations for the scheduler.
type Store struct {
db *gorm.DB
}
// NewStore creates a Store.
// Tables (queue_items, processed_items)
func NewStore(db *gorm.DB) (*Store, error) {
if db == nil {
return nil, fmt.Errorf("scheduler: db is nil")
}
return &Store{db: db}, nil
}
// DB returns the underlying *gorm.DB used by the store.
func (s *Store) DB() *gorm.DB {
return s.db
}
// Enqueue inserts a new pending task.
// maxRuns controls how many times the task executes: 1 = once (default), -1 = repeat indefinitely.
// thread controls concurrency: 0 = sequential per type, 1 = concurrent per type.
// Any value of maxRuns < -1 is clamped to 1.
func (s *Store) Enqueue(ctx context.Context, taskType, payload string, priority, maxRuns, thread int) (*QueueItem, error) {
if maxRuns < -1 {
maxRuns = 1
}
item := &QueueItem{
TaskType: taskType,
Payload: payload,
Status: StatusPending,
Priority: priority,
MaxRuns: maxRuns,
Thread: thread,
}
if err := s.db.WithContext(ctx).Create(item).Error; err != nil {
return nil, fmt.Errorf("enqueue %s: %w", taskType, err)
}
return item, nil
}
// ClaimBatch atomically claims up to `limit` pending tasks and marks them
// as "processing" so that concurrent scheduler instances never double-execute.
func (s *Store) ClaimBatch(ctx context.Context, limit int) ([]*QueueItem, error) {
var items []*QueueItem
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
query := tx.
Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ?", StatusPending).
Order("priority DESC, id ASC").
Limit(limit).
Find(&items)
if query.Error != nil {
return query.Error
}
if len(items) == 0 {
return nil
}
ids := make([]uint, len(items))
for i, it := range items {
ids[i] = it.ID
}
return tx.Model(&QueueItem{}).
Where("id IN ?", ids).
Update("status", StatusProcessing).
Error
})
if err != nil {
return nil, fmt.Errorf("claim batch: %w", err)
}
return items, nil
}
// RecordResult writes a ProcessedItem, then either deletes the QueueItem (if
// MaxRuns is exhausted) or decrements MaxRuns and resets the status to pending
// for re-execution on the next tick. A maxRuns of -1 means repeat indefinitely.
func (s *Store) RecordResult(ctx context.Context, item *QueueItem, startedAt time.Time, execErr error) error {
finishedAt := time.Now()
status := ProcessedOK
errMsg := ""
if execErr != nil {
status = ProcessedError
errMsg = execErr.Error()
}
processed := &ProcessedItem{
QueueItemID: item.ID,
TaskType: item.TaskType,
Payload: item.Payload,
Status: status,
ErrorMsg: errMsg,
StartedAt: startedAt,
FinishedAt: finishedAt,
DurationMs: finishedAt.Sub(startedAt).Milliseconds(),
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Insert the immutable result record.
if err := tx.Create(processed).Error; err != nil {
return fmt.Errorf("insert processed_item: %w", err)
}
// 2. Decide: delete the queue item or re-queue it.
// MaxRuns == 1 means "run once" — delete.
// MaxRuns == -1 means "repeat indefinitely" — always re-queue.
// MaxRuns > 1 means "run N times" — decrement and re-queue.
if item.MaxRuns == 1 {
// Exhausted — delete.
if err := tx.Delete(&QueueItem{}, item.ID).Error; err != nil {
return fmt.Errorf("delete queue_item %d: %w", item.ID, err)
}
} else {
// Decrement MaxRuns (if not -1/infinite) and set back to pending.
newMaxRuns := item.MaxRuns
if newMaxRuns > 1 {
newMaxRuns--
}
if err := tx.Model(&QueueItem{}).
Where("id = ?", item.ID).
Updates(map[string]interface{}{
"status": StatusPending,
"max_runs": newMaxRuns,
}).Error; err != nil {
return fmt.Errorf("re-queue queue_item %d: %w", item.ID, err)
}
}
return nil
})
}
// SemaphoreKey is the key used in scheduler_meta for the semaphore state.
const SemaphoreKey = "semaphore"
// SemaphoreValueGreen is the stored value for a green semaphore.
const SemaphoreValueGreen = "green"
// SemaphoreValueRed is the stored value for a red semaphore.
const SemaphoreValueRed = "red"
// SetSemaphore persists the semaphore state to the database.
func (s *Store) SetSemaphore(ctx context.Context, green bool) error {
value := SemaphoreValueRed
if green {
value = SemaphoreValueGreen
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var count int64
tx.Model(&SchedulerMeta{}).Where("key = ?", SemaphoreKey).Count(&count)
if count == 0 {
return tx.Create(&SchedulerMeta{Key: SemaphoreKey, Value: value}).Error
}
return tx.Model(&SchedulerMeta{}).
Where("key = ?", SemaphoreKey).
Update("value", value).Error
})
}
// GetSemaphore reads the persisted semaphore state from the database.
// Returns true if green, false if red or not found.
func (s *Store) GetSemaphore(ctx context.Context) bool {
var meta SchedulerMeta
err := s.db.WithContext(ctx).Where("key = ?", SemaphoreKey).First(&meta).Error
if err != nil || meta.Value != SemaphoreValueGreen {
return false
}
return true
}
// PendingCount returns the number of tasks currently in pending or processing state.
func (s *Store) PendingCount(ctx context.Context) (int64, error) {
var count int64
err := s.db.WithContext(ctx).
Model(&QueueItem{}).
Where("status IN ?", []QueueStatus{StatusPending, StatusProcessing}).
Count(&count).Error
return count, err
}
// RecentProcessed returns the last `n` processed items ordered by newest first.
func (s *Store) RecentProcessed(ctx context.Context, n int) ([]*ProcessedItem, error) {
var items []*ProcessedItem
err := s.db.WithContext(ctx).
Order("id DESC").
Limit(n).
Find(&items).Error
return items, err
}
// RecoverStuck resets any items stuck in "processing" state (e.g. after a
// crash) back to "pending" so they can be re-claimed on the next tick.
// Call this once at startup.
func (s *Store) RecoverStuck(ctx context.Context) (int64, error) {
result := s.db.WithContext(ctx).
Model(&QueueItem{}).
Where("status = ?", StatusProcessing).
Update("status", StatusPending)
return result.RowsAffected, result.Error
}
// ─── Semaphore ───────────────────────────────────────────────────────────────
// Semaphore is the interface for the scheduler kill switch.
// Green (true) means tasks can run; Red (false) means tasks are blocked.
type Semaphore interface {
SetGreen()
SetRed()
IsGreen() bool
}
// inMemorySemaphore is the default channel-based implementation.
type inMemorySemaphore struct {
green chan bool
}
// NewSemaphore creates a Semaphore. Pass true to start green, false to start red.
func NewSemaphore(startGreen bool) Semaphore {
s := &inMemorySemaphore{green: make(chan bool, 1)}
s.green <- startGreen
return s
}
func (s *inMemorySemaphore) SetGreen() {
<-s.green
s.green <- true
}
func (s *inMemorySemaphore) SetRed() {
<-s.green
s.green <- false
}
func (s *inMemorySemaphore) IsGreen() bool {
val := <-s.green
s.green <- val
return val
}
// PersistedSemaphore implements Semaphore and persists every state change to
// the database via the Store, so the state survives application restarts.
type PersistedSemaphore struct {
inner Semaphore
store *Store
ctx context.Context
}
// NewPersistedSemaphore creates a PersistedSemaphore with an initial state.
func NewPersistedSemaphore(store *Store, startGreen bool) *PersistedSemaphore {
return &PersistedSemaphore{
inner: NewSemaphore(startGreen),
store: store,
ctx: context.Background(),
}
}
// SetGreen allows task execution and persists the state.
func (ps *PersistedSemaphore) SetGreen() {
ps.inner.SetGreen()
_ = ps.store.SetSemaphore(ps.ctx, true)
}
// SetRed blocks task execution and persists the state.
func (ps *PersistedSemaphore) SetRed() {
ps.inner.SetRed()
_ = ps.store.SetSemaphore(ps.ctx, false)
}
// IsGreen reports whether execution is currently allowed.
func (ps *PersistedSemaphore) IsGreen() bool {
return ps.inner.IsGreen()
}
// ─── Scheduler ───────────────────────────────────────────────────────────────
// Scheduler polls the database every interval and executes pending tasks.
type Scheduler struct {
interval time.Duration
rateLimit int // max tasks per tick; 0 = unlimited
semaphore Semaphore
store *Store
registry *Registry
stop chan struct{}
done chan struct{}
}
// SchedulerConfig holds constructor options.
type SchedulerConfig struct {
Interval time.Duration
RateLimit int // 0 = unlimited
}
// NewScheduler creates a Scheduler.
func NewScheduler(cfg SchedulerConfig, sem Semaphore, st *Store, reg *Registry) *Scheduler {
return &Scheduler{
interval: cfg.Interval,
rateLimit: cfg.RateLimit,
semaphore: sem,
store: st,
registry: reg,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Start launches the scheduler goroutine.
func (s *Scheduler) Start(ctx context.Context) {
go s.run(ctx)
}
// Stop signals the scheduler and waits for it to exit cleanly.
func (s *Scheduler) Stop() {
close(s.stop)
<-s.done
}
// run is the main event loop.
func (s *Scheduler) run(ctx context.Context) {
defer close(s.done)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.ResolveLogger().Info("[scheduler] context cancelled, shutting down")
return
case <-s.stop:
logger.ResolveLogger().Info("[scheduler] stop requested, shutting down")
return
case t := <-ticker.C:
s.tick(ctx, t)
}
}
}
// tick runs on every ticker event.
func (s *Scheduler) tick(ctx context.Context, t time.Time) {
ts := t.Format(time.TimeOnly)
if !s.semaphore.IsGreen() {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] tick %s — semaphore RED, skipping", ts))
return
}
limit := s.rateLimit
if limit == 0 {
limit = 1000
}
items, err := s.store.ClaimBatch(ctx, limit)
if err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] tick %s — claim error: %v", ts, err))
return
}
if len(items) == 0 {
return
}
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] tick %s — executing %d task(s)", ts, len(items)))
// Split items into sequential vs concurrent groups.
var sequential []*QueueItem
concurrentGroups := make(map[string][]*QueueItem) // taskType -> items
for _, item := range items {
if item.Thread != 0 {
concurrentGroups[item.TaskType] = append(concurrentGroups[item.TaskType], item)
} else {
sequential = append(sequential, item)
}
}
// Execute sequential items one at a time.
for i, item := range sequential {
s.execute(ctx, item, i+1, len(items))
}
// Execute concurrent groups in parallel per type.
for _, group := range concurrentGroups {
s.executeGroup(ctx, group, len(items))
}
if remaining, err := s.store.PendingCount(ctx); err == nil {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] tick %s — done (%d task(s) still queued)", ts, remaining))
}
}
// execute runs a task's handler one or more times within the same tick.
// When Thread=0 (sequential), runs execute one after another in a loop.
// When Thread=1 (concurrent), runs fire simultaneously in goroutines.
// The number of runs per tick is capped by the rate limit, and the item
// is re-queued with remaining runs or deleted when exhausted.
func (s *Scheduler) execute(ctx context.Context, item *QueueItem, pos, total int) {
// Determine how many runs to fire this tick.
desiredRuns := item.MaxRuns
if desiredRuns < 1 {
desiredRuns = 1 // -1 (infinite): one batch per tick
}
// Cap by the rate limit so one item cannot exhaust the tick budget.
budget := s.rateLimit
if budget <= 0 {
budget = 1000
}
if desiredRuns > budget {
desiredRuns = budget
}
remainingRuns := 0
if item.MaxRuns == -1 {
remainingRuns = -1
} else {
remainingRuns = item.MaxRuns - desiredRuns
if remainingRuns < 0 {
remainingRuns = 0
}
}
isConcurrent := item.Thread != 0
if isConcurrent {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d type=%s firing %d/%d concurrent run(s)", pos, total, item.ID, item.TaskType, desiredRuns, item.MaxRuns))
} else {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d type=%s firing %d/%d sequential run(s)", pos, total, item.ID, item.TaskType, desiredRuns, item.MaxRuns))
}
if isConcurrent {
// ── Concurrent: all runs in parallel goroutines ──
var wg sync.WaitGroup
wg.Add(desiredRuns)
for i := 0; i < desiredRuns; i++ {
go func(runIdx int) {
defer wg.Done()
startedAt := time.Now()
handler := s.registry.get(item.TaskType)
execErr := handler(ctx, item)
finishedAt := time.Now()
elapsed := finishedAt.Sub(startedAt).Milliseconds()
status := ProcessedOK
errMsg := ""
if execErr != nil {
status = ProcessedError
errMsg = execErr.Error()
}
processed := &ProcessedItem{
QueueItemID: item.ID,
TaskType: item.TaskType,
Payload: item.Payload,
Status: status,
ErrorMsg: errMsg,
StartedAt: startedAt,
FinishedAt: finishedAt,
DurationMs: elapsed,
}
if err := s.store.db.WithContext(ctx).Create(processed).Error; err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] failed to record processed_item for id=%d run=%d: %v", pos, total, item.ID, runIdx+1, err))
}
if execErr != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] task id=%d run=%d FAILED after %dms: %v", pos, total, item.ID, runIdx+1, elapsed, execErr))
} else {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d run=%d OK (%dms)", pos, total, item.ID, runIdx+1, elapsed))
}
}(i)
}
wg.Wait()
} else {
// ── Sequential: all runs one after another ──
for i := 0; i < desiredRuns; i++ {
startedAt := time.Now()
handler := s.registry.get(item.TaskType)
execErr := handler(ctx, item)
finishedAt := time.Now()
elapsed := finishedAt.Sub(startedAt).Milliseconds()
status := ProcessedOK
errMsg := ""
if execErr != nil {
status = ProcessedError
errMsg = execErr.Error()
}
processed := &ProcessedItem{
QueueItemID: item.ID,
TaskType: item.TaskType,
Payload: item.Payload,
Status: status,
ErrorMsg: errMsg,
StartedAt: startedAt,
FinishedAt: finishedAt,
DurationMs: elapsed,
}
if err := s.store.db.WithContext(ctx).Create(processed).Error; err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] failed to record processed_item for id=%d run=%d: %v", pos, total, item.ID, i+1, err))
}
if execErr != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] task id=%d run=%d FAILED after %dms: %v", pos, total, item.ID, i+1, elapsed, execErr))
} else {
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d run=%d OK (%dms)", pos, total, item.ID, i+1, elapsed))
}
}
}
// ── After all runs: decide the queue item's fate ──
if remainingRuns == -1 {
if err := s.store.db.WithContext(ctx).
Model(&QueueItem{}).
Where("id = ?", item.ID).
Updates(map[string]interface{}{
"status": StatusPending,
"max_runs": -1,
}).Error; err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] failed to re-queue infinite item id=%d: %v", pos, total, item.ID, err))
}
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d re-queued for next tick (infinite)", pos, total, item.ID))
} else if remainingRuns > 0 {
if err := s.store.db.WithContext(ctx).
Model(&QueueItem{}).
Where("id = ?", item.ID).
Updates(map[string]interface{}{
"status": StatusPending,
"max_runs": remainingRuns,
}).Error; err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] failed to re-queue item id=%d with %d remaining runs: %v", pos, total, item.ID, remainingRuns, err))
}
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d re-queued with %d run(s) remaining", pos, total, item.ID, remainingRuns))
} else {
if err := s.store.db.WithContext(ctx).Delete(&QueueItem{}, item.ID).Error; err != nil {
logger.ResolveLogger().Error(fmt.Sprintf("[scheduler] [%d/%d] failed to delete finished item id=%d: %v", pos, total, item.ID, err))
}
logger.ResolveLogger().Info(fmt.Sprintf("[scheduler] [%d/%d] task id=%d all runs exhausted, deleted", pos, total, item.ID))
}
}
// executeGroup runs all items in a group concurrently using goroutines.
// Each item's result is recorded independently.
func (s *Scheduler) executeGroup(ctx context.Context, items []*QueueItem, total int) {
var wg sync.WaitGroup
wg.Add(len(items))
for _, item := range items {
item := item // capture range variable
go func() {
defer wg.Done()
// Find the item's position among all items for logging context.
// Since we don't have global position here, use a local index.
s.execute(ctx, item, 0, total)
}()
}
wg.Wait()
}

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

@ -1,6 +1,7 @@
package components package components
type ContentDropdown struct { type ContentDropdown struct {
ID string
Label string Label string
TypeClass string // type primary, secondary, success, danger, warning, info, light, dark, link, outline-primary TypeClass string // type primary, secondary, success, danger, warning, info, light, dark, link, outline-primary
IsDisabled bool IsDisabled bool

View file

@ -1,10 +1,12 @@
package components package components
type ContentList struct { type ContentList struct {
ID string
Items []ContentListItem Items []ContentListItem
} }
type ContentListItem struct { type ContentListItem struct {
ID string
Text string Text string
Description string Description string
EndElement string EndElement string

View file

@ -1,8 +1,10 @@
{{define "content_list"}} {{define "content_list"}}
<ul class="list-group"> <ul class="list-group" {{ if .ID }}id="{{.ID}}"{{end}}>
{{ range .Items}} {{ range .Items}}
<li class="list-group-item text-wrap {{if eq .IsDisabled true}}disabled{{end}} {{if .TypeClass}}list-group-item-{{.TypeClass}}{{end}}" <li class="list-group-item text-wrap {{if eq .IsDisabled true}}disabled{{end}}
{{if eq .IsDisabled true}}aria-disabled="true"{{end}} {{if .TypeClass}}list-group-item-{{.TypeClass}}{{end}}"
{{if eq .IsDisabled true}}aria-disabled="true"{{end}}
{{ if .ID }}id="{{.ID}}"{{end}}
><div class="d-flex justify-content-between lh-sm"><p class="mb-1">{{.Text}}</p><span class="text-body-secondary ms-5">{{.EndElement}}</span></div> ><div class="d-flex justify-content-between lh-sm"><p class="mb-1">{{.Text}}</p><span class="text-body-secondary ms-5">{{.EndElement}}</span></div>
<small>{{.Description}}</small> <small>{{.Description}}</small>
</li> </li>

View file

@ -0,0 +1,9 @@
package components
type ContentPagination struct {
PageStartRecord int
PageEndRecord int
TotalRecords int
PrevLink string
NextLink string
}

View file

@ -0,0 +1,10 @@
{{define "content_pagination"}}
<div class="pagination-container">
<ul class="pagination pagination-sm">
<li class="page-item">{{.PageStartRecord}} - {{.PageEndRecord}} of {{.TotalRecords}}</li>
<li class="page-item">&nbsp;</li>
<li class="page-item"><a class="page-link{{if eq .PrevLink ""}} disabled"{{else}}" href="{{.PrevLink}}"{{end}}>«</a></li>
<li class="page-item"><a class="page-link{{if eq .NextLink ""}} disabled"{{else}}" href="{{.NextLink}}"{{end}}>»</a></li>
</ul>
</div>
{{end}}

View file

@ -10,11 +10,12 @@ type ContentTable struct {
type ContentTableTH struct { type ContentTableTH struct {
ID string ID string
ValueType string // -> default string, href, badge ValueType string // -> default string, href, badge, list, checkbox
Value string Value string
} }
type ContentTableTD struct { type ContentTableTD struct {
ID string ID string
Value interface{} // string or component struct according ValueType Value interface{} // string or component struct according ValueType
ValueClass string
} }

View file

@ -7,12 +7,18 @@
</thead> </thead>
<tbody> <tbody>
{{- range .AllTD}}<tr scope="row"> {{- range .AllTD}}<tr scope="row">
{{range $index, $item := .}}<td {{ if $item.ID }}id="{{$item.ID}}"{{end}}> {{range $index, $item := .}}<td {{ if $item.ID }}id="{{$item.ID}}"{{end}}{{ if $item.ValueClass }} class="{{$item.ValueClass}}"{{end}}>
{{ with $x := index $.AllTH $index }} {{ with $x := index $.AllTH $index }}
{{ if eq $x.ValueType "href"}} {{ if eq $x.ValueType "href"}}
{{template "content_href" $item.Value}} {{template "content_href" $item.Value}}
{{ else if eq $x.ValueType "badge"}} {{ else if eq $x.ValueType "badge"}}
{{template "content_badge" $item.Value}} {{template "content_badge" $item.Value}}
{{ else if eq $x.ValueType "list"}}
{{template "content_list" $item.Value}}
{{ else if eq $x.ValueType "checkbox"}}
{{template "form_checkbox" $item.Value}}
{{ else if eq $x.ValueType "image"}}
<img src="{{ $item.Value }}">
{{ else }} {{ else }}
{{ $item.Value }} {{ $item.Value }}
{{end}} {{end}}

View file

@ -1,6 +1,8 @@
package components package components
type FormButton struct { type FormButton struct {
ID string
Value string
Text string Text string
Icon string Icon string
IsSubmit bool IsSubmit bool

View file

@ -1,5 +1,5 @@
{{define "form_button"}} {{define "form_button"}}
<button class="btn btn-{{.TypeClass}}" {{if eq .IsSubmit true}}type="submit"{{else}}type="button"{{end}} {{if .IsDisabled}}disabled{{end}}> <button {{if .ID }}id="{{.ID}}" name="{{.ID}}"{{end}} {{if .Value }}value="{{.Value}}" name="{{.ID}}"{{end}} class="btn btn-{{.TypeClass}}" {{if eq .IsSubmit true}}type="submit"{{else}}type="button"{{end}} {{if .IsDisabled}}disabled{{end}}>
{{.Text}} {{.Text}}
</button> </button>
<!-- tailwind heroicons --> <!-- tailwind heroicons -->

View file

@ -1,6 +1,6 @@
{{define "form_checkbox"}} {{define "form_checkbox"}}
<div class="input-container"> <div class="input-container">
<label class="form-label">{{.Label}}</label> {{ if .Label }}<label class="form-label">{{.Label}}</label>{{end}}
{{range $options := .AllCheckbox}} {{range $options := .AllCheckbox}}
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="{{$options.Name}}" id="{{$options.ID}}" value="{{$options.Value}}"{{if eq $options.IsChecked true}} checked{{end}}> <input class="form-check-input" type="checkbox" name="{{$options.Name}}" id="{{$options.ID}}" value="{{$options.Value}}"{{if eq $options.IsChecked true}} checked{{end}}>

View file

@ -1,13 +1,20 @@
package components package components
type FormInput struct { type FormInput struct {
ID string ID string
Label string Label string
Type string Type string
Placeholder string Placeholder string
Value string Value string
Hint string Hint string
Error string Error string
IsDisabled bool IsDisabled bool
IsRequired bool Autocomplete bool
IsRequired bool
CustomAtt []CustomAtt
}
type CustomAtt struct {
AttName string
AttValue string
} }

View file

@ -8,9 +8,17 @@
{{if eq .IsRequired true}} {{if eq .IsRequired true}}
required required
{{end}} {{end}}
{{if eq .Autocomplete false}}
autocomplete="off"
{{end}}
{{if ne .Value ""}} {{if ne .Value ""}}
value="{{.Value}}" value="{{.Value}}"
{{end}} {{end}}
{{if .CustomAtt }}
{{range $options := .CustomAtt}}
{{$options.AttName}}="{{$options.AttValue}}"
{{end}}
{{end}}
aria-describedby="{{.ID}}Help"> aria-describedby="{{.ID}}Help">
{{if ne .Hint ""}}<small id="{{.ID}}Help" class="form-text text-muted">{{.Hint}}</small>{{end}} {{if ne .Hint ""}}<small id="{{.ID}}Help" class="form-text text-muted">{{.Hint}}</small>{{end}}
{{if ne .Error ""}}<div class="error">{{.Error}}</div>{{end}} {{if ne .Error ""}}<div class="error">{{.Error}}</div>{{end}}

View file

@ -5,6 +5,7 @@ type FormSelect struct {
SelectedOption FormSelectOption SelectedOption FormSelectOption
Label string Label string
AllOptions []FormSelectOption AllOptions []FormSelectOption
IsMultiple bool
} }
type FormSelectOption struct { type FormSelectOption struct {

View file

@ -1,7 +1,7 @@
{{define "form_select"}} {{define "form_select"}}
<div class="input-container"> <div class="input-container">
<label for="{{.ID}}" class="form-label">{{.Label}}</label> <label for="{{.ID}}" class="form-label">{{.Label}}</label>
<select class="form-select" id="{{.ID}}" name="{{.ID}}"> <select class="form-select" id="{{.ID}}" name="{{.ID}}"{{if eq .IsMultiple true}} multiple{{end}}>
{{range $options := .AllOptions}} {{range $options := .AllOptions}}
<option value="{{$options.Value}}" {{if eq $options.Value $.SelectedOption.Value }}selected="selected"{{end}}>{{$options.Caption}}</option> <option value="{{$options.Value}}" {{if eq $options.Value $.SelectedOption.Value }}selected="selected"{{end}}>{{$options.Caption}}</option>
{{end}} {{end}}

View file

@ -10,6 +10,7 @@ type PageNav struct {
type PageNavItem struct { type PageNavItem struct {
Text string Text string
Link string Link string
ID string
IsDisabled bool IsDisabled bool
IsActive bool IsActive bool
ChildItems []PageNavItem ChildItems []PageNavItem

View file

@ -1,11 +1,16 @@
{{define "page_nav"}} {{define "page_nav"}}
<ul class="nav justify-content-center {{.NavClass}} {{if eq .IsVertical true}}flex-column{{end}}{{if eq .IsTab true}}nav-tabs{{end}}" {{if eq .IsTab true}}role="tablist"{{end}}> <ul class="nav justify-content-center {{.NavClass}} {{if eq .IsVertical true}}flex-column{{end}}{{if eq .IsTab true}}nav-tabs{{end}}" {{if eq .IsTab true}}role="tablist"{{end}}>
{{range $item := .NavItems}} {{range $item := .NavItems}}
{{if gt (len $item.ChildItems) 0}} {{if gt (len $item.ChildItems) 0}}
{{ $hasActiveChild := false }}
{{range $child := $item.ChildItems}}
{{if $child.IsActive}}
{{ $hasActiveChild = true }}
{{end}}
{{end}}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a href="{{$item.Link}}" {{if eq .IsDisabled true}}disabled{{end}} data-bs-toggle="dropdown" <a href="{{$item.Link}}" {{if eq .IsDisabled true}}disabled{{end}} data-bs-toggle="dropdown"
class="nav-link dropdown-toggle {{if eq .IsActive true}}active{{end}} {{if eq .IsDisabled true}}disabled{{end}}">{{$item.Text}}</a> class="nav-link dropdown-toggle {{if or $item.IsActive $hasActiveChild}}active{{end}} {{if eq .IsDisabled true}}disabled{{end}}">{{$item.Text}}</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{{ range $item.ChildItems}} {{ range $item.ChildItems}}
{{template "content_dropdown_item" .}} {{template "content_dropdown_item" .}}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2024 Zeni Kim <zenik@smarteching.com> // Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
// Use of this source code is governed by MIT-style // Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -6,15 +6,337 @@ package core
import ( import (
"embed" "embed"
"fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"math"
"reflect"
"strings" "strings"
"time"
"unicode"
"golang.org/x/text/language"
"golang.org/x/text/message"
) )
var tmpl *template.Template = nil var tmpl *template.Template = nil
// ─────────────────────────────────────────────
// Struct helpers
// ─────────────────────────────────────────────
// hasField checks if a struct has a given field name.
// It can be used inside Go html/templates like:
//
// {{if hasField . "FieldA"}}<a>{{.FieldA}}</a>{{end}}
func hasField(v interface{}, name string) bool {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return false
}
return rv.FieldByName(name).IsValid()
}
// ─────────────────────────────────────────────
// String helpers
// ─────────────────────────────────────────────
// capitalize uppercases the first letter of a string and lowercases the rest.
//
// {{capitalize "hello world"}} → "Hello world"
func capitalize(s string) string {
if s == "" {
return ""
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
for i := 1; i < len(runes); i++ {
runes[i] = unicode.ToLower(runes[i])
}
return string(runes)
}
// prepend adds a prefix string to the beginning of s.
//
// {{prepend "World" "Hello, "}} → "Hello, World"
func prepend(s, prefix string) string {
return prefix + s
}
// strAppend adds a suffix string to the end of s.
// Named strAppend to avoid collision with the builtin append keyword.
//
// {{strAppend "Hello" "!"}} → "Hello!"
func strAppend(s, suffix string) string {
return s + suffix
}
// split divides s into a slice of substrings separated by sep.
// Useful when combined with range in templates.
//
// {{range split .Tags ","}}...{{end}}
func split(s, sep string) []string {
return strings.Split(s, sep)
}
// truncate cuts s to at most n characters. Designed for pipeline use:
// {{.Title | truncate 30}}
func truncate(n int, s string) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
// ─────────────────────────────────────────────
// Number helpers
// ─────────────────────────────────────────────
// fmtNumber formats an integer or float with thousands separators
// using the English locale (e.g. 1000000 → "1,000,000").
//
// {{fmtNumber .Price}}
func fmtNumber(v interface{}) string {
p := message.NewPrinter(language.English)
switch n := v.(type) {
case int:
return p.Sprintf("%d", n)
case int64:
return p.Sprintf("%d", n)
case float64:
// trim unnecessary trailing zeros
formatted := p.Sprintf("%.2f", n)
return formatted
case float32:
return p.Sprintf("%.2f", n)
default:
return fmt.Sprintf("%v", v)
}
}
// ─────────────────────────────────────────────
// Date & time helpers
// ─────────────────────────────────────────────
// fmtDate formats a time.Time value using a named layout or a custom Go layout string.
// Named layouts: "short" → "02 Jan 2006", "long" → "02 January 2006",
// "iso" → "2006-01-02", "datetime" → "02 Jan 2006 15:04".
// Any other string is used directly as a Go time layout.
//
// {{fmtDate .PublishedAt "short"}}
// {{fmtDate .CreatedAt "02/01/2006"}}
func fmtDate(t time.Time, layout string) string {
switch layout {
case "short":
return t.Format("02 Jan 2006")
case "long":
return t.Format("02 January 2006")
case "iso":
return t.Format("2006-01-02")
case "datetime":
return t.Format("02 Jan 2006 15:04")
default:
return t.Format(layout)
}
}
// timeAgo returns a human-readable relative time string from now.
//
// {{timeAgo .PublishedAt}} → "3 hours ago", "2 days ago", "just now"
func timeAgo(t time.Time) string {
diff := time.Since(t)
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
m := int(math.Round(diff.Minutes()))
return plural(m, "minute") + " ago"
case diff < 24*time.Hour:
h := int(math.Round(diff.Hours()))
return plural(h, "hour") + " ago"
case diff < 7*24*time.Hour:
d := int(math.Round(diff.Hours() / 24))
return plural(d, "day") + " ago"
case diff < 30*24*time.Hour:
w := int(math.Round(diff.Hours() / 24 / 7))
return plural(w, "week") + " ago"
case diff < 365*24*time.Hour:
mo := int(math.Round(diff.Hours() / 24 / 30))
return plural(mo, "month") + " ago"
default:
y := int(math.Round(diff.Hours() / 24 / 365))
return plural(y, "year") + " ago"
}
}
// plural is an internal helper that returns "1 item" or "N items".
func plural(n int, unit string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, unit)
}
return fmt.Sprintf("%d %ss", n, unit)
}
// ─────────────────────────────────────────────
// Collection helpers
// ─────────────────────────────────────────────
// first returns the first element of a slice, or nil if empty.
//
// {{with first .Items}}{{.Name}}{{end}}
func first(slice interface{}) interface{} {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
return nil
}
return rv.Index(0).Interface()
}
// last returns the last element of a slice, or nil if empty.
//
// {{with last .Items}}{{.Name}}{{end}}
func last(slice interface{}) interface{} {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
return nil
}
return rv.Index(rv.Len() - 1).Interface()
}
// sliceOf returns a sub-range of a slice from index start (inclusive) to end (exclusive).
// Named sliceOf to avoid collision with the builtin slice expression.
//
// {{range sliceOf .Items 0 3}}...{{end}}
func sliceOf(slice interface{}, start, end int) interface{} {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice {
return nil
}
if start < 0 {
start = 0
}
if end > rv.Len() {
end = rv.Len()
}
return rv.Slice(start, end).Interface()
}
// contains reports whether item is present in slice (or substr in a string).
//
// {{if contains .Tags "go"}}...{{end}}
// {{if contains .Bio "engineer"}}...{{end}}
func contains(collection, item interface{}) bool {
// string substring check
if s, ok := collection.(string); ok {
if sub, ok := item.(string); ok {
return strings.Contains(s, sub)
}
return false
}
rv := reflect.ValueOf(collection)
if rv.Kind() != reflect.Slice {
return false
}
for i := 0; i < rv.Len(); i++ {
if reflect.DeepEqual(rv.Index(i).Interface(), item) {
return true
}
}
return false
}
// join concatenates the elements of a string slice with sep as separator.
//
// {{join .Tags ", "}}
func join(slice []string, sep string) string {
return strings.Join(slice, sep)
}
// ─────────────────────────────────────────────
// Logic helpers
// ─────────────────────────────────────────────
// defaultVal returns fallback if value is the zero value or an empty string.
//
// {{defaultVal "N/A" .OptionalField}}
func defaultVal(fallback, value interface{}) interface{} {
if value == nil {
return fallback
}
rv := reflect.ValueOf(value)
if rv.IsZero() {
return fallback
}
return value
}
// ternary returns trueVal if condition is true, falseVal otherwise.
//
// {{ternary "Active" "Inactive" .IsActive}}
func ternary(trueVal, falseVal interface{}, condition bool) interface{} {
if condition {
return trueVal
}
return falseVal
}
// coalesce returns the first non-empty, non-nil value from the provided list.
//
// {{coalesce .Nickname .FullName "Anonymous"}}
func coalesce(values ...interface{}) interface{} {
for _, v := range values {
if v == nil {
continue
}
rv := reflect.ValueOf(v)
if !rv.IsZero() {
return v
}
}
return nil
}
// ─────────────────────────────────────────────
// FuncMap registry
// ─────────────────────────────────────────────
func funcMap() template.FuncMap {
return template.FuncMap{
// Struct
"hasField": hasField,
// String
"capitalize": capitalize,
"prepend": prepend,
"strAppend": strAppend,
"split": split,
"truncate": truncate,
// Number
"fmtNumber": fmtNumber,
// Date & time
"fmtDate": fmtDate,
"timeAgo": timeAgo,
// Collections
"first": first,
"last": last,
"sliceOf": sliceOf,
"contains": contains,
"join": join,
// Logic
"defaultVal": defaultVal,
"ternary": ternary,
"coalesce": coalesce,
}
}
// ─────────────────────────────────────────────
// Template initializer
// ─────────────────────────────────────────────
func NewTemplates(components embed.FS, templates embed.FS) { func NewTemplates(components embed.FS, templates embed.FS) {
// templates
var paths []string var paths []string
fs.WalkDir(components, ".", func(path string, d fs.DirEntry, err error) error { fs.WalkDir(components, ".", func(path string, d fs.DirEntry, err error) error {
if strings.Contains(d.Name(), ".html") { if strings.Contains(d.Name(), ".html") {
@ -31,6 +353,28 @@ func NewTemplates(components embed.FS, templates embed.FS) {
return nil return nil
}) })
tmpla := template.Must(template.ParseFS(components, paths...)) tmpl = template.Must(
tmpl = template.Must(tmpla.ParseFS(templates, pathst...)) template.New("").
Funcs(funcMap()).
ParseFS(components, paths...),
)
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
} }