init
Some checks failed
Build and Release / release (push) Failing after 51s

This commit is contained in:
2026-03-25 22:29:07 +07:00
parent eedd300861
commit 79199f627d
65 changed files with 3215 additions and 689 deletions

127
pkg/cache/redis.go vendored
View File

@@ -2,31 +2,136 @@ package cache
import (
"context"
"encoding/json"
"fmt"
"history-api/pkg/config"
"time"
"github.com/redis/go-redis/v9"
)
var RI *redis.Client
type Cache interface {
Set(ctx context.Context, key string, value any, ttl time.Duration) error
Get(ctx context.Context, key string, dest any) error
Del(ctx context.Context, keys ...string) error
DelByPattern(ctx context.Context, pattern string) error
MGet(ctx context.Context, keys ...string) [][]byte
MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error
}
func Connect() error {
connectionURI, err := config.GetConfig("REDIS_CONNECTION_URI")
type RedisClient struct {
client *redis.Client
}
func NewRedisClient() (Cache, error) {
uri, err := config.GetConfig("REDIS_CONNECTION_URI")
if err != nil {
return err
return nil, err
}
rdb := redis.NewClient(&redis.Options{
Addr: connectionURI,
Password: "",
DB: 0,
Addr: uri,
MinIdleConns: 10,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
MaxRetries: 3,
MinRetryBackoff: 8 * time.Millisecond,
MaxRetryBackoff: 512 * time.Millisecond,
DisableIdentity: true,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("Could not connect to Redis: %v", err)
return nil, fmt.Errorf("could not connect to Redis: %v", err)
}
RI = rdb
return nil
return &RedisClient{client: rdb}, nil
}
func (r *RedisClient) Del(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return r.client.Del(ctx, keys...).Err()
}
func (r *RedisClient) DelByPattern(ctx context.Context, pattern string) error {
var cursor uint64
for {
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err)
}
if len(keys) > 0 {
if err := r.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("error deleting keys during scan: %v", err)
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return nil
}
func (r *RedisClient) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return r.client.Set(ctx, key, data, ttl).Err()
}
func (r *RedisClient) Get(ctx context.Context, key string, dest any) error {
data, err := r.client.Get(ctx, key).Bytes()
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
func (r *RedisClient) MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error {
pipe := r.client.Pipeline()
for key, value := range pairs {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal key %s: %v", key, err)
}
pipe.Set(ctx, key, data, ttl)
}
_, err := pipe.Exec(ctx)
return err
}
func (r *RedisClient) MGet(ctx context.Context, keys ...string) [][]byte {
res, err := r.client.MGet(ctx, keys...).Result()
if err != nil {
return nil
}
results := make([][]byte, len(res))
for i, val := range res {
if val != nil {
results[i] = []byte(val.(string))
}
}
return results
}
func GetMultiple[T any](ctx context.Context, c Cache, keys []string) ([]T, error) {
raws := c.MGet(ctx, keys...)
final := make([]T, 0)
for _, b := range raws {
if b == nil {
continue
}
var item T
if err := json.Unmarshal(b, &item); err == nil {
final = append(final, item)
}
}
return final, nil
}

39
pkg/constant/regex.go Normal file
View File

@@ -0,0 +1,39 @@
package constant
import (
"errors"
"regexp"
)
var (
// Password components (Go-compatible)
hasUpper = regexp.MustCompile(`[A-Z]`)
hasLower = regexp.MustCompile(`[a-z]`)
hasNumber = regexp.MustCompile(`\d`)
hasSpecial = regexp.MustCompile(`[!@#$%^&*()_+{}|:<>?~-]`)
// Standard Regexes
PHONE_NUMBER_REGEX = regexp.MustCompile(`^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$`)
EMAIL_REGEX = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
YOUTUBE_VIDEO_ID_REGEX = regexp.MustCompile(`(?:\/|v=|\/v\/|embed\/|watch\?v=|watch\?.+&v=)([\w-]{11})`)
BANK_INPUT = regexp.MustCompile(`[__]{2,}`)
)
func ValidatePassword(password string) error {
if len(password) < 8 {
return errors.New("password must be at least 8 characters long")
}
if !hasUpper.MatchString(password) {
return errors.New("password must contain at least one uppercase letter")
}
if !hasLower.MatchString(password) {
return errors.New("password must contain at least one lowercase letter")
}
if !hasNumber.MatchString(password) {
return errors.New("password must contain at least one number")
}
if !hasSpecial.MatchString(password) {
return errors.New("password must contain at least one special character")
}
return nil
}

View File

@@ -22,6 +22,13 @@ func CheckValidRole(r Role) bool {
return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED
}
func (r Role) ToSlice() []string {
return []string{r.String()}
func ParseRole(s string) (Role, bool) {
r := Role(s)
if CheckValidRole(r) {
return r, true
}
return "", false
}
func (r Role) ToSlice() []Role {
return []Role{r}
}

View File

@@ -1,6 +1,10 @@
package convert
import "github.com/jackc/pgx/v5/pgtype"
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func UUIDToString(v pgtype.UUID) string {
if v.Valid {
@@ -23,10 +27,10 @@ func BoolVal(v pgtype.Bool) bool {
return false
}
func TimeToPtr(v pgtype.Timestamptz) *string {
if v.Valid {
t := v.Time.Format("2006-01-02T15:04:05Z07:00")
return &t
func TimeToPtr(v pgtype.Timestamptz) *time.Time {
if !v.Valid {
return nil
}
return nil
t := v.Time
return &t
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect() (*pgxpool.Pool, error) {
func NewPostgresqlDB() (*pgxpool.Pool, error) {
ctx := context.Background()
connectionURI, err := config.GetConfig("PGX_CONNECTION_URI")
if err != nil {

View File

@@ -1,13 +0,0 @@
package request
type SignUpDto struct {
Password string `json:"password" validate:"required"`
DiscordUserId string `json:"discord_user_id" validate:"required"`
Username string `json:"username" validate:"required"`
}
type LoginDto struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}

View File

@@ -1,21 +0,0 @@
package request
type PreSignedDto struct {
FileName string `json:"fileName" validate:"required"`
ContentType string `json:"contentType" validate:"required"`
}
type PreSignedCompleteDto struct {
FileName string `json:"fileName" validate:"required"`
MediaId string `json:"mediaId" validate:"required"`
PublicUrl string `json:"publicUrl" validate:"required"`
}
type SearchMediaDto struct {
MediaId string `query:"media_id" validate:"omitempty"`
FileName string `query:"file_name" validate:"omitempty"`
SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"`
Order string `query:"order" default:"desc" validate:"oneof=asc desc"`
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"10" validate:"min=1,max=100"`
}

View File

@@ -1,26 +0,0 @@
package request
import "history-api/pkg/constant"
type CreateUserDto struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
DiscordUserId string `json:"discord_user_id" validate:"required"`
Role []constant.Role `json:"role" validate:"required"`
}
type UpdateUserDto struct {
Password *string `json:"password" validate:"omitempty"`
DiscordUserId *string `json:"discord_user_id" validate:"omitempty"`
Role *[]constant.Role `json:"role" validate:"omitempty"`
}
type SearchUserDto struct {
Username *string `query:"username" validate:"omitempty"`
DiscordUserId *string `query:"discord_user_id" validate:"omitempty"`
Role *[]constant.Role `query:"role" validate:"omitempty"`
SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"`
Order string `query:"order" default:"desc" validate:"oneof=asc desc"`
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"10" validate:"min=1,max=100"`
}

View File

@@ -1,6 +0,0 @@
package response
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

View File

@@ -1,19 +0,0 @@
package response
import (
"history-api/pkg/constant"
"github.com/golang-jwt/jwt/v5"
)
type CommonResponse struct {
Status bool `json:"status"`
Data any `json:"data"`
Message string `json:"message"`
}
type JWTClaims struct {
UId string `json:"uid"`
Roles []constant.Role `json:"roles"`
jwt.RegisteredClaims
}

View File

@@ -1,9 +0,0 @@
package response
type PreSignedResponse struct {
UploadUrl string `json:"uploadUrl"`
PublicUrl string `json:"publicUrl"`
FileName string `json:"fileName"`
MediaId string `json:"mediaId"`
SignedHeaders map[string]string `json:"signedHeaders"`
}

View File

@@ -1,14 +0,0 @@
package response
type RoleSimpleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
}
type RoleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
IsDeleted bool `json:"is_deleted"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}

View File

@@ -1,9 +0,0 @@
package response
type TokenResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TokenType int16 `json:"token_type"`
ExpiresAt *string `json:"expires_at"`
CreatedAt *string `json:"created_at"`
}

View File

@@ -1,15 +0,0 @@
package response
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
IsDeleted bool `json:"is_deleted"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
Roles []*RoleSimpleResponse `json:"roles"`
}

26
pkg/mbtiles/db.go Normal file
View File

@@ -0,0 +1,26 @@
package mbtiles
import (
"database/sql"
"fmt"
_ "github.com/glebarez/go-sqlite"
)
func NewMBTilesDB(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=off&_synchronous=off", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
return db, nil
}

View File

@@ -1,54 +0,0 @@
package models
import (
"history-api/pkg/dtos/response"
"history-api/pkg/convert"
"github.com/jackc/pgx/v5/pgtype"
)
type RoleSimple struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
}
func (r *RoleSimple) ToResponse() *response.RoleSimpleResponse {
return &response.RoleSimpleResponse{
ID: convert.UUIDToString(r.ID),
Name: r.Name,
}
}
func RolesToResponse(rs []*RoleSimple) []*response.RoleSimpleResponse {
out := make([]*response.RoleSimpleResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}
type RoleEntity struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (r *RoleEntity) ToResponse() *response.RoleResponse {
return &response.RoleResponse{
ID: convert.UUIDToString(r.ID),
Name: r.Name,
IsDeleted: convert.BoolVal(r.IsDeleted),
CreatedAt: convert.TimeToPtr(r.CreatedAt),
UpdatedAt: convert.TimeToPtr(r.UpdatedAt),
}
}
func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
out := make([]*response.RoleResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}

View File

@@ -1,35 +0,0 @@
package models
import (
"history-api/pkg/convert"
"history-api/pkg/dtos/response"
"github.com/jackc/pgx/v5/pgtype"
)
type TokenEntity struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"`
TokenType int16 `json:"token_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (t *TokenEntity) ToResponse() *response.TokenResponse {
return &response.TokenResponse{
ID: convert.UUIDToString(t.ID),
UserID: convert.UUIDToString(t.UserID),
TokenType: t.TokenType,
ExpiresAt: convert.TimeToPtr(t.ExpiresAt),
CreatedAt: convert.TimeToPtr(t.CreatedAt),
}
}
func TokensEntityToResponse(ts []*TokenEntity) []*response.TokenResponse {
out := make([]*response.TokenResponse, len(ts))
for i := range ts {
out[i] = ts[i].ToResponse()
}
return out
}

View File

@@ -1,58 +0,0 @@
package models
import (
"encoding/json"
"history-api/pkg/convert"
"history-api/pkg/dtos/response"
"github.com/jackc/pgx/v5/pgtype"
)
type UserEntity struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Roles []*RoleSimple `json:"roles"`
}
func (u *UserEntity) ParseRoles(data []byte) error {
if len(data) == 0 {
u.Roles = []*RoleSimple{}
return nil
}
return json.Unmarshal(data, &u.Roles)
}
func (u *UserEntity) ToResponse() *response.UserResponse {
return &response.UserResponse{
ID: convert.UUIDToString(u.ID),
Name: u.Name,
Email: u.Email,
AvatarUrl: convert.TextToString(u.AvatarUrl),
IsActive: convert.BoolVal(u.IsActive),
IsVerified: convert.BoolVal(u.IsVerified),
TokenVersion: u.TokenVersion,
IsDeleted: convert.BoolVal(u.IsDeleted),
CreatedAt: convert.TimeToPtr(u.CreatedAt),
UpdatedAt: convert.TimeToPtr(u.UpdatedAt),
Roles: RolesToResponse(u.Roles),
}
}
func UsersEntityToResponse(rs []*UserEntity) []*response.UserResponse {
out := make([]*response.UserResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}

View File

@@ -41,8 +41,18 @@ func formatValidationError(err error) []ErrorResponse {
element.FailedField = fieldError.Field()
element.Tag = fieldError.Tag()
element.Value = fieldError.Param()
element.Message = "Field " + fieldError.Field() + " failed validation on tag '" + fieldError.Tag() + "'"
switch fieldError.Tag() {
case "required":
element.Message = fieldError.Field() + " is required"
case "min":
element.Message = fieldError.Field() + " must be at least " + fieldError.Param() + " characters"
case "max":
element.Message = fieldError.Field() + " must be at most " + fieldError.Param() + " characters"
case "email":
element.Message = "Invalid email format"
default:
element.Message = fieldError.Error()
}
errorsList = append(errorsList, element)
}
}
@@ -79,4 +89,4 @@ func ValidateBodyDto(c fiber.Ctx, dto any) error {
}
return nil
}
}