Initial commit

This commit is contained in:
2026-01-19 12:12:29 +07:00
commit 2072052437
42 changed files with 5450 additions and 0 deletions

1
pkg/auth/interface.go Normal file
View File

@@ -0,0 +1 @@
package auth

1
pkg/auth/jwt.go Normal file
View File

@@ -0,0 +1 @@
package auth

14
pkg/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,14 @@
package cache
import (
"context"
"time"
)
// Cache defines the interface for caching operations
type Cache interface {
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Get(ctx context.Context, key string) (string, error)
Del(ctx context.Context, key string) error
Close() error
}

1
pkg/cache/interface.go vendored Normal file
View File

@@ -0,0 +1 @@
package cache

43
pkg/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,43 @@
package cache
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type redisCache struct {
client *redis.Client
}
// NewRedisCache creates a new instance of Redis cache implementing Cache interface
func NewRedisCache(addr, password string, db int) (Cache, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, err
}
return &redisCache{client: rdb}, nil
}
func (c *redisCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return c.client.Set(ctx, key, value, expiration).Err()
}
func (c *redisCache) Get(ctx context.Context, key string) (string, error) {
return c.client.Get(ctx, key).Result()
}
func (c *redisCache) Del(ctx context.Context, key string) error {
return c.client.Del(ctx, key).Err()
}
func (c *redisCache) Close() error {
return c.client.Close()
}

17
pkg/database/postgres.go Normal file
View File

@@ -0,0 +1,17 @@
package database
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func Connect(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
return db, nil
}

51
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,51 @@
package logger
import (
"log"
"go.uber.org/zap"
)
// Logger defines the interface for logging
type Logger interface {
Info(msg string, fields ...interface{})
Error(msg string, fields ...interface{})
Debug(msg string, fields ...interface{})
Warn(msg string, fields ...interface{})
}
type zapLogger struct {
logger *zap.SugaredLogger
}
func NewLogger(mode string) Logger {
var l *zap.Logger
var err error
if mode == "release" {
l, err = zap.NewProduction()
} else {
l, err = zap.NewDevelopment()
}
if err != nil {
log.Fatalf("Failed to initialize logger: %v", err)
}
return &zapLogger{logger: l.Sugar()}
}
func (l *zapLogger) Info(msg string, fields ...interface{}) {
l.logger.Infow(msg, fields...)
}
func (l *zapLogger) Error(msg string, fields ...interface{}) {
l.logger.Errorw(msg, fields...)
}
func (l *zapLogger) Debug(msg string, fields ...interface{}) {
l.logger.Debugw(msg, fields...)
}
func (l *zapLogger) Warn(msg string, fields ...interface{}) {
l.logger.Warnw(msg, fields...)
}

44
pkg/response/response.go Normal file
View File

@@ -0,0 +1,44 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Success sends a success response with 200 OK
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: http.StatusOK,
Message: "success",
Data: data,
})
}
// Created sends a success response with 201 Created
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, Response{
Code: http.StatusCreated,
Message: "created",
Data: data,
})
}
// Error sends an error response with the specified status code
func Error(c *gin.Context, code int, message string) {
c.AbortWithStatusJSON(code, Response{
Code: code,
Message: message,
})
}
// Fail sends an internal server error response (500)
func Fail(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, message)
}

69
pkg/storage/s3.go Normal file
View File

@@ -0,0 +1,69 @@
package storage
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"stream.api/internal/config"
)
type Provider interface {
GeneratePresignedURL(key string, expire time.Duration) (string, error)
Delete(key string) error
}
type s3Provider struct {
client *s3.Client
presignClient *s3.PresignClient
bucket string
}
func NewS3Provider(cfg *config.Config) (Provider, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(
context.TODO(),
awsconfig.WithRegion(cfg.AWS.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AWS.AccessKey, cfg.AWS.SecretKey, "")),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = cfg.AWS.ForcePathStyle
if cfg.AWS.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.AWS.Endpoint)
}
})
return &s3Provider{
client: client,
presignClient: s3.NewPresignClient(client),
bucket: cfg.AWS.Bucket,
}, nil
}
func (s *s3Provider) GeneratePresignedURL(key string, expire time.Duration) (string, error) {
req, err := s.presignClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, func(o *s3.PresignOptions) {
o.Expires = expire
})
if err != nil {
return "", err
}
return req.URL, nil
}
func (s *s3Provider) Delete(key string) error {
_, err := s.client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}

26
pkg/token/interface.go Normal file
View File

@@ -0,0 +1,26 @@
package token
// TokenPair contains the access and refresh tokens
type TokenPair struct {
AccessToken string
RefreshToken string
AccessUUID string
RefreshUUID string
AtExpires int64
RtExpires int64
}
// Claims defines the JWT claims (User)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
TokenID string `json:"token_id"`
}
// Provider defines the interface for token operations
type Provider interface {
GenerateTokenPair(userID, email, role string) (*TokenPair, error)
ParseToken(tokenString string) (*Claims, error)
ParseMapToken(tokenString string) (map[string]interface{}, error)
}

119
pkg/token/jwt.go Normal file
View File

@@ -0,0 +1,119 @@
package token
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// jwtClaims is an internal struct to satisfy jwt.Claims interface
type jwtClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
TokenID string `json:"token_id"`
jwt.RegisteredClaims
}
// jwt.go implements the Provider interface using JWT
type jwtProvider struct {
secret string
}
// NewJWTProvider creates a new instance of JWT provider
func NewJWTProvider(secret string) Provider {
return &jwtProvider{
secret: secret,
}
}
// GenerateTokenPair generates new access and refresh tokens
func (p *jwtProvider) GenerateTokenPair(userID, email, role string) (*TokenPair, error) {
td := &TokenPair{}
td.AtExpires = time.Now().Add(time.Minute * 15).Unix()
td.AccessUUID = uuid.New().String()
td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix() // Expires in 7 days
td.RefreshUUID = uuid.New().String()
// Access Token
atClaims := &jwtClaims{
UserID: userID,
Email: email,
Role: role,
TokenID: td.AccessUUID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(td.AtExpires, 0)),
Issuer: "stream.api",
},
}
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
var err error
td.AccessToken, err = at.SignedString([]byte(p.secret))
if err != nil {
return nil, err
}
// Refresh Token
// Refresh token can just be a random string or a JWT.
// Common practice: JWT for stateless verification, or Opaque string for stateful.
// Here we use JWT so we can carry some metadata if needed, but we check Redis anyway.
rtClaims := jwt.MapClaims{}
rtClaims["refresh_uuid"] = td.RefreshUUID
rtClaims["user_id"] = userID
rtClaims["exp"] = td.RtExpires
rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims)
td.RefreshToken, err = rt.SignedString([]byte(p.secret))
if err != nil {
return nil, err
}
return td, nil
}
// ParseToken parses the access token returning Claims
func (p *jwtProvider) ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(p.secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*jwtClaims); ok && token.Valid {
return &Claims{
UserID: claims.UserID,
Email: claims.Email,
Role: claims.Role,
TokenID: claims.TokenID,
}, nil
}
return nil, fmt.Errorf("invalid token")
}
// ParseMapToken parses token returning map[string]interface{} (generic)
func (p *jwtProvider) ParseMapToken(tokenString string) (map[string]interface{}, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(p.secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrTokenInvalidClaims
}