This commit is contained in:
2024-10-13 13:31:58 +04:00
commit aec8d7ed48
54 changed files with 2827 additions and 0 deletions

View File

@ -0,0 +1,76 @@
package jwt
import (
"errors"
"time"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type JWTService struct {
key string
duration time.Duration
}
type tokenPayload struct {
UserID uuid.UUID `json:"user_id,omitempty"`
jwt.RegisteredClaims
}
func NewJWTService(duration time.Duration, key string) *JWTService {
return &JWTService{
key: key,
duration: duration,
}
}
func (jwtService *JWTService) CreateToken(
user *domain.User,
) (string, error) {
token := jwt.NewWithClaims(
jwt.SigningMethodHS256,
&tokenPayload{
UserID: user.ID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(
time.Now().Add(jwtService.duration),
),
Audience: jwt.ClaimStrings{"user"},
IssuedAt: jwt.NewNumericDate(time.Now()),
},
},
)
tokenStr, err := token.SignedString([]byte(jwtService.key))
if err != nil {
return "", domain.ErrTokenCreation
}
return tokenStr, nil
}
func (jwtService *JWTService) VerifyToken(
token string,
) (*domain.AuthPayload, error) {
keyFunc := func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, domain.ErrInvalidToken
}
return []byte(jwtService.key), nil
}
parsedToken, err := jwt.ParseWithClaims(token, &tokenPayload{}, keyFunc)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, domain.ErrExpiredToken
}
if errors.Is(err, jwt.ErrSignatureInvalid) {
return nil, domain.ErrInvalidToken
}
return nil, domain.ErrInternal
}
payload := parsedToken.Claims.(*tokenPayload)
return &domain.AuthPayload{UserID: payload.UserID}, nil
}

View File

@ -0,0 +1,25 @@
package config
import (
"time"
"github.com/aykhans/oh-my-chat/internal/core/utils"
)
type AppConfig struct {
IsDev bool
CORSAllowedOrigins string
ListenerPort int
SecretKey string
JWTDuration *time.Duration
}
func NewAppConfig() *AppConfig {
return &AppConfig{
IsDev: utils.GetEnvOrDefault("APP_IS_PROD", "true") == "true",
CORSAllowedOrigins: utils.GetEnvOrDefault("APP_CORS_ALLOWED_ORIGINS", "*"),
ListenerPort: Str2IntOrDie(GetEnvOrDie("APP_LISTENER_PORT")),
SecretKey: GetEnvOrDie("APP_SECRET_KEY"),
JWTDuration: Str2DurationOrDie(GetEnvOrDie("APP_JWT_DURATION")),
}
}

View File

@ -0,0 +1,17 @@
package config
type ContainerConfig struct {
*AppConfig
*PostgresConfig
*KafkaConfig
*ScyllaConfig
}
func NewContainerConfig() *ContainerConfig {
return &ContainerConfig{
NewAppConfig(),
NewPostgresConfig(),
NewKafkaConfig(),
NewScyllaConfig(),
}
}

View File

@ -0,0 +1,44 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
"github.com/aykhans/oh-my-chat/internal/core/utils"
)
var log = logger.NewStdLogger()
func GetEnvOrDie(key string) string {
value := os.Getenv(key)
if value == "" {
log.Error(
"Error get environment variable",
"error",
fmt.Errorf("Environment variable "+key+" is not set"),
)
utils.ExitErr()
}
return value
}
func Str2IntOrDie(value string) int {
intValue, err := strconv.Atoi(value)
if err != nil {
log.Error("Error convert string to int", "error", err)
utils.ExitErr()
}
return intValue
}
func Str2DurationOrDie(value string) *time.Duration {
duration, err := time.ParseDuration(value)
if err != nil {
log.Error("Error convert string to duration", "error", err)
utils.ExitErr()
}
return &duration
}

View File

@ -0,0 +1,45 @@
package config
import (
"strings"
"github.com/aykhans/oh-my-chat/internal/core/utils"
)
type BootstrapServers []string
func (b *BootstrapServers) String() string {
return strings.Join(*b, ",")
}
type KafkaConfig struct {
*KafkaProducerConfig
*KafkaConsumerConfig
}
type KafkaProducerConfig struct {
BootstrapServers BootstrapServers
}
type KafkaConsumerConfig struct {
BootstrapServers BootstrapServers
}
func NewKafkaConfig() *KafkaConfig {
return &KafkaConfig{
NewKafkaProducerConfig(),
NewKafkaConsumerConfig(),
}
}
func NewKafkaProducerConfig() *KafkaProducerConfig {
return &KafkaProducerConfig{
BootstrapServers: utils.Str2StrSlice(GetEnvOrDie("KAFKA_PRODUCER_BOOTSTRAP_SERVERS")),
}
}
func NewKafkaConsumerConfig() *KafkaConsumerConfig {
return &KafkaConsumerConfig{
BootstrapServers: utils.Str2StrSlice(GetEnvOrDie("KAFKA_CONSUMER_BOOTSTRAP_SERVERS")),
}
}

View File

@ -0,0 +1,19 @@
package config
type PostgresConfig struct {
User string
Password string
Host string
Port string
DBName string
}
func NewPostgresConfig() *PostgresConfig {
return &PostgresConfig{
User: GetEnvOrDie("POSTGRES_USER"),
Password: GetEnvOrDie("POSTGRES_PASSWORD"),
Host: GetEnvOrDie("POSTGRES_HOST"),
Port: GetEnvOrDie("POSTGRES_PORT"),
DBName: GetEnvOrDie("POSTGRES_DB"),
}
}

View File

@ -0,0 +1,21 @@
package config
import "github.com/aykhans/oh-my-chat/internal/core/utils"
type ScyllaConfig struct {
Hosts []string
DataCenter string
Keyspace string
User string
Password string
}
func NewScyllaConfig() *ScyllaConfig {
return &ScyllaConfig{
Hosts: utils.Str2StrSlice(GetEnvOrDie("SCYLLA_HOSTS")),
DataCenter: GetEnvOrDie("SCYLLA_DATACENTER"),
Keyspace: GetEnvOrDie("SCYLLA_KEYSPACE"),
User: GetEnvOrDie("SCYLLA_USER"),
Password: GetEnvOrDie("SCYLLA_PASSWORD"),
}
}

View File

@ -0,0 +1,76 @@
package http
import (
"github.com/aykhans/oh-my-chat/internal/adapter/handlers/http/middlewares"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/recover"
)
type Handlers struct {
userHandler *UserHandler
authHandler *AuthHandler
chatHandler *ChatHandler
}
type Middlewares struct {
authMiddleware *middlewares.AuthMiddleware
wsMiddleware *middlewares.WSMiddleware
}
func NewApp(
isDev bool,
corsAllowedOrigins string,
authMiddleware *middlewares.AuthMiddleware,
wsMiddleware *middlewares.WSMiddleware,
userHandler *UserHandler,
authHandler *AuthHandler,
chatHandler *ChatHandler,
) *fiber.App {
handlers := &Handlers{
userHandler: userHandler,
authHandler: authHandler,
chatHandler: chatHandler,
}
middlewares := &Middlewares{
authMiddleware: authMiddleware,
wsMiddleware: wsMiddleware,
}
app := fiber.New()
if !isDev {
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: corsAllowedOrigins,
}))
}
router := app.Group("/api")
setV1Routers(router, handlers, middlewares)
return app
}
func setV1Routers(
router fiber.Router,
handlers *Handlers,
middlewares *Middlewares,
) {
router = router.Group("/v1")
{ // User routes
user := router.Group("/user")
user.Post("/register", handlers.userHandler.Register)
}
{ // Auth routes
auth := router.Group("/auth")
auth.Post("/login", handlers.authHandler.Login)
}
{ // Chat routes
chat := router.Group("/chat")
chat.Use("/ws", middlewares.authMiddleware.IsUser, middlewares.wsMiddleware.Upgrade)
chat.Get("/ws", websocket.New(handlers.chatHandler.Connect))
}
}

View File

@ -0,0 +1,50 @@
package http
import (
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
type AuthHandler struct {
authService port.AuthService
validator *validator.Validate
}
func NewAuthHandler(authService port.AuthService, validator *validator.Validate) *AuthHandler {
return &AuthHandler{authService, validator}
}
type loginRequest struct {
Username string `json:"username"`
Email string `json:"email" validate:"email"`
Password string `json:"password" validate:"required"`
}
func (authHandler *AuthHandler) Login(ctx *fiber.Ctx) error {
loginBody := new(loginRequest)
if err := ctx.BodyParser(loginBody); err != nil {
return invalidRequestBodyResponse(ctx)
}
if err := authHandler.validator.Struct(loginBody); err != nil {
return validationErrorResponse(ctx, err)
}
loginField := loginBody.Email
loginFunc := authHandler.authService.LoginByEmail
if loginField == "" {
if loginBody.Username == "" {
return fiber.NewError(fiber.StatusBadRequest, "email or username is required")
}
loginField = loginBody.Username
loginFunc = authHandler.authService.LoginByUsername
}
serviceCtx := ctx.Context()
token, err := loginFunc(serviceCtx, loginField, loginBody.Password)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
return ctx.JSON(fiber.Map{"token": token})
}

View File

@ -0,0 +1,127 @@
package http
import (
"context"
"encoding/json"
"time"
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/go-playground/validator/v10"
"github.com/gofiber/contrib/websocket"
"github.com/google/uuid"
)
var log = logger.NewStdLogger()
type ChatHandler struct {
messageService port.MessageService
validator *validator.Validate
}
func NewChatHandler(
messageService port.MessageService,
validator *validator.Validate,
) *ChatHandler {
return &ChatHandler{messageService, validator}
}
type messageReuqest struct {
Content string `json:"content" validate:"required"`
To string `json:"to" validate:"required"`
}
func (chatHandler *ChatHandler) Connect(conn *websocket.Conn) {
authPayload := getAuthPayloadInWS(conn)
streamReceiveMessageCtx, streamReceiveMessageCtxCancel := context.WithCancel(context.Background())
consumeMessageChan := make(chan *domain.StreamMessage)
wsMessageChan := make(chan *domain.Message)
go readMessageFromWS(
conn,
chatHandler.validator,
authPayload.UserID,
wsMessageChan,
)
go func() {
err := chatHandler.messageService.ReceiveMessage(
streamReceiveMessageCtx,
authPayload.UserID,
consumeMessageChan,
)
if err != nil {
return
}
}()
defer func() {
streamReceiveMessageCtxCancel()
}()
var err error
for {
select {
case streamReceivedMessage := <-consumeMessageChan:
messageBytes, _ := json.Marshal(streamReceivedMessage.Message)
if err = conn.WriteMessage(websocket.TextMessage, messageBytes); err != nil {
return
}
err := streamReceivedMessage.Commit()
if err != nil {
log.Error("Stream message commit error", "error", err.Error())
return
}
case wsMessage := <-wsMessageChan:
if wsMessage == nil {
return
}
streamSendMessageCtx := context.Background()
chatHandler.messageService.SendMessage(streamSendMessageCtx, wsMessage)
}
}
}
func readMessageFromWS(
conn *websocket.Conn,
validator *validator.Validate,
userID uuid.UUID,
messageChan chan<- *domain.Message,
) {
var (
wsReceivedMessageType int
wsReceivedMessage []byte
err error
)
for {
if wsReceivedMessageType, wsReceivedMessage, err = conn.ReadMessage(); err != nil {
messageChan <- nil
break
}
if wsReceivedMessageType == websocket.TextMessage {
messageBody := new(messageReuqest)
if err := json.Unmarshal(wsReceivedMessage, &messageBody); err != nil {
messageChan <- nil
break
}
if err := validator.Struct(messageBody); err != nil {
messageChan <- nil
break
}
timestamp := time.Now()
messageChan <- &domain.Message{
UserID: userID,
ChatID: messageBody.To,
Content: messageBody.Content,
Timestamp: timestamp,
}
}
}
}

View File

@ -0,0 +1,23 @@
package http
import (
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/gofiber/contrib/websocket"
// "github.com/gofiber/fiber/v2"
)
// func getAuthPayload(ctx *fiber.Ctx) *domain.AuthPayload {
// payload := ctx.Locals("authPayload")
// if payload == nil {
// return nil
// }
// return payload.(*domain.AuthPayload)
// }
func getAuthPayloadInWS(conn *websocket.Conn) *domain.AuthPayload {
payload := conn.Locals("authPayload")
if payload == nil {
return nil
}
return payload.(*domain.AuthPayload)
}

View File

@ -0,0 +1,33 @@
package middlewares
import (
"fmt"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/gofiber/fiber/v2"
)
type AuthMiddleware struct {
tokenService port.TokenService
}
func NewAuthMiddleware(tokenService port.TokenService) *AuthMiddleware {
return &AuthMiddleware{tokenService}
}
func (authMiddleware *AuthMiddleware) IsUser(ctx *fiber.Ctx) error {
token := ctx.Get("Authorization")
if token == "" {
return fiber.NewError(fiber.StatusBadRequest, "Authorization header is required")
}
payload, err := authMiddleware.tokenService.VerifyToken(token[7:])
if err != nil {
if err == domain.ErrInternal {
fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error")
}
return fiber.NewError(fiber.StatusUnauthorized, fmt.Sprintf("Unauthorized: %v", err))
}
ctx.Locals("authPayload", payload)
return ctx.Next()
}

View File

@ -0,0 +1,20 @@
package middlewares
import (
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
)
type WSMiddleware struct{}
func NewWSMiddleware() *WSMiddleware {
return &WSMiddleware{}
}
func (wsMiddleware *WSMiddleware) Upgrade(ctx *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(ctx) {
ctx.Locals("allowed", true)
return ctx.Next()
}
return fiber.ErrUpgradeRequired
}

View File

@ -0,0 +1,29 @@
package http
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
func notFoundResponse(ctx *fiber.Ctx, err ...error) error {
errMsg := "Not found"
if len(err) > 0 {
errMsg = err[0].Error()
}
return ctx.Status(fiber.StatusNotFound).JSON(
fiber.Map{"error": errMsg},
)
}
func invalidRequestBodyResponse(ctx *fiber.Ctx) error {
return ctx.Status(fiber.StatusBadRequest).JSON(
fiber.Map{"error": "Invalid request body"},
)
}
func validationErrorResponse(ctx *fiber.Ctx, err error) error {
errs := err.(validator.ValidationErrors)
return ctx.Status(fiber.StatusBadRequest).JSON(
fiber.Map{"errors": validationErrorFormater(errs)},
)
}

View File

@ -0,0 +1,57 @@
package http
import (
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
type UserHandler struct {
userService port.UserService
validator *validator.Validate
}
func NewUserHandler(svc port.UserService, validator *validator.Validate) *UserHandler {
return &UserHandler{svc, validator}
}
type registerRequest struct {
Username string `json:"username" validate:"required,max=50"`
Email string `json:"email" validate:"email"`
Password string `json:"password" validate:"min=8,max=72"`
}
func (userHandler *UserHandler) Register(ctx *fiber.Ctx) error {
registerBody := new(registerRequest)
if err := ctx.BodyParser(registerBody); err != nil {
return invalidRequestBodyResponse(ctx)
}
if err := userHandler.validator.Struct(registerBody); err != nil {
return validationErrorResponse(ctx, err)
}
user := domain.User{
Username: registerBody.Username,
Email: registerBody.Email,
Password: registerBody.Password,
}
serviceCtx := ctx.Context()
_, err := userHandler.userService.Register(serviceCtx, &user)
if err != nil {
if err == domain.ErrUsernameExists || err == domain.ErrEmailExists {
return notFoundResponse(ctx, err)
}
return fiber.ErrInternalServerError
}
return ctx.Status(fiber.StatusCreated).JSON(
fiber.Map{
"ID": user.ID,
"Username": user.Username,
"Email": user.Email,
},
)
}

View File

@ -0,0 +1,60 @@
package http
import (
"fmt"
"reflect"
"github.com/go-playground/validator/v10"
)
type fieldError struct {
Field string
Message string
}
func NewValidator() *validator.Validate {
validate := validator.New()
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
if fld.Tag.Get("validation_name") != "" {
return fld.Tag.Get("validation_name")
} else {
return fld.Tag.Get("json")
}
})
return validate
}
func validationErrorFormater(errs validator.ValidationErrors) []fieldError {
fieldErrs := make([]fieldError, 0)
if errs != nil {
for _, err := range errs {
fieldErrs = append(
fieldErrs,
fieldError{
Field: err.Field(),
Message: msgForTag(err.Tag(), err.Field(), err.Param()),
},
)
}
return fieldErrs
}
return nil
}
func msgForTag(tag, field, param string) string {
switch tag {
case "required":
return fmt.Sprintf("%s is required", field)
case "email":
return "email is invalid"
case "min":
return fmt.Sprintf("The length of %s must be at least %s", field, param)
case "max":
return fmt.Sprintf("The length of %s must be at most %s", field, param)
default:
return fmt.Sprintf("%s is invalid", field)
}
}

View File

@ -0,0 +1,15 @@
package logger
import (
"log/slog"
"os"
)
func NewStdLogger() *slog.Logger {
return slog.New(
slog.NewJSONHandler(
os.Stdout,
&slog.HandlerOptions{AddSource: true},
),
)
}

View File

@ -0,0 +1,38 @@
package postgres
import (
"fmt"
"time"
"github.com/aykhans/oh-my-chat/internal/adapter/config"
postgresDriver "gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewDB(config *config.PostgresConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s",
config.Host,
config.User,
config.Password,
config.DBName,
config.Port,
"disable",
"UTC",
)
var db *gorm.DB
var err error
for range 3 {
db, err = gorm.Open(postgresDriver.Open(dsn), &gorm.Config{})
if err == nil {
break
}
time.Sleep(3 * time.Second)
}
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS "public"."users";

View File

@ -0,0 +1,11 @@
CREATE TABLE "public"."users" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"username" character varying(50) NOT NULL,
"email" text NOT NULL,
"password" character varying(72) NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "uni_users_email" UNIQUE ("email"),
CONSTRAINT "uni_users_username" UNIQUE ("username")
);

View File

@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
)
const (
UserTableName = "users"
)
type User struct {
ID uuid.UUID `gorm:"primarykey;unique;type:uuid;default:gen_random_uuid()"`
Username string `gorm:"unique;not null;size:50"`
Email string `gorm:"unique;not null"`
Password string `gorm:"not null;size:72"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (u User) TableName() string {
return UserTableName
}

View File

@ -0,0 +1,109 @@
package repository
import (
"context"
"errors"
"github.com/aykhans/oh-my-chat/internal/adapter/storages/postgres/models"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db}
}
func (userRepository *UserRepository) CreateUser(
ctx context.Context,
user *domain.User,
) (*domain.User, error) {
userModel := &models.User{
Username: user.Username,
Email: user.Email,
Password: user.Password,
}
tx := userRepository.db.Create(userModel)
if tx.Error != nil {
return nil, tx.Error
}
user.ID = userModel.ID
user.Username = userModel.Username
user.Email = userModel.Email
user.Password = userModel.Password
return user, nil
}
func (userRepository *UserRepository) IsUsernameExists(
ctx context.Context,
username string,
) (bool, error) {
var count int64
tx := userRepository.db.
Table(models.UserTableName).
Where("username = ?", username).
Count(&count)
if tx.Error != nil {
return false, tx.Error
}
return count > 0, nil
}
func (userRepository *UserRepository) IsEmailExists(
ctx context.Context,
email string,
) (bool, error) {
var count int64
tx := userRepository.db.
Table(models.UserTableName).
Where("email = ?", email).
Count(&count)
if tx.Error != nil {
return false, tx.Error
}
return count > 0, nil
}
func (userRepository *UserRepository) GetUserByEmail(
ctx context.Context,
email string,
) (*domain.User, error) {
user := &domain.User{}
tx := userRepository.db.
Table(models.UserTableName).
Where("email = ?", email).
First(user)
if tx.Error != nil {
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, domain.ErrDataNotFound
}
return nil, tx.Error
}
return user, nil
}
func (userRepository *UserRepository) GetUserByUsername(
ctx context.Context,
username string,
) (*domain.User, error) {
user := &domain.User{}
tx := userRepository.db.
Table(models.UserTableName).
Where("username = ?", username).
First(user)
if tx.Error != nil {
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, domain.ErrDataNotFound
}
return nil, tx.Error
}
return user, nil
}

View File

@ -0,0 +1,33 @@
package scylla
import (
"time"
"github.com/aykhans/oh-my-chat/internal/adapter/config"
"github.com/gocql/gocql"
)
func NewDB(config *config.ScyllaConfig) (*gocql.Session, error) {
cluster := gocql.NewCluster(config.Hosts...)
cluster.Keyspace = config.Keyspace
cluster.Consistency = gocql.LocalQuorum
cluster.Authenticator = gocql.PasswordAuthenticator{
Username: config.User,
Password: config.Password,
}
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(
gocql.DCAwareRoundRobinPolicy(config.DataCenter),
)
var session *gocql.Session
var err error
for range 20 {
session, err = cluster.CreateSession()
if err == nil {
return session, nil
}
time.Sleep(3 * time.Second)
}
return nil, err
}

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS messages;

View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS messages (
chat_id UUID, -- Partition key
user_id UUID,
content text, -- Clustering column
type text, -- Clustering column
created_at timestamp, -- Clustering column
PRIMARY KEY (chat_id, created_at, content, type)
) WITH CLUSTERING ORDER BY (created_at DESC);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS user_chats;

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_chats (
user_id UUID, -- Partition key
chat_id UUID, -- Clustering column
blocked boolean,
created_at timestamp,
PRIMARY KEY (user_id, created_at, chat_id, blocked)
);

View File

@ -0,0 +1,23 @@
package repository
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/gocql/gocql"
)
type MessageRepository struct {
db *gocql.Session
}
func NewMessageRepository(db *gocql.Session) *MessageRepository {
return &MessageRepository{db}
}
func (messageRepository *MessageRepository) CreateMessage(
ctx context.Context,
message *domain.Message,
) (*domain.Message, error) {
return nil, nil
}

View File

@ -0,0 +1,94 @@
package consumer
import (
"context"
"encoding/json"
"io"
"time"
"github.com/aykhans/oh-my-chat/internal/adapter/config"
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/google/uuid"
"github.com/segmentio/kafka-go"
)
var log = logger.NewStdLogger()
type MessageConsumer struct {
kafkaConsumerConfig *config.KafkaConsumerConfig
}
type ConsumerMessage struct {
UserID uuid.UUID `json:"user_id"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
func NewMessageConsumer(consumerConfig *config.KafkaConsumerConfig) *MessageConsumer {
return &MessageConsumer{consumerConfig}
}
func (messageConsumer *MessageConsumer) ConsumeMessage(
ctx context.Context,
uid string,
getChats func() []string,
message chan<- *domain.StreamMessage,
) error {
consumer := kafka.NewReader(kafka.ReaderConfig{
Brokers: messageConsumer.kafkaConsumerConfig.BootstrapServers,
GroupID: uid,
GroupTopics: getChats(),
MaxBytes: 10e6, // 10MB
ReadLagInterval: -1,
MaxWait: 300 * time.Millisecond,
GroupBalancers: []kafka.GroupBalancer{kafka.RoundRobinGroupBalancer{}},
StartOffset: kafka.FirstOffset,
})
defer func() {
if err := consumer.Close(); err != nil {
log.Error("Error closing consumer", "error", err.Error())
}
}()
for {
msg, err := consumer.FetchMessage(ctx)
if err != nil {
switch err {
case io.EOF:
return nil
case context.Canceled:
return nil
}
log.Error("Error fetching message from kafka", "error", err.Error())
continue
}
consumerMeesage := &ConsumerMessage{}
err = json.Unmarshal(msg.Value, consumerMeesage)
if err != nil {
log.Error("Error unmarshalling message", "error", err.Error())
return domain.ErrInternal
}
message <- &domain.StreamMessage{
Message: &domain.Message{
UserID: consumerMeesage.UserID,
ChatID: consumerMeesage.ChatID,
Content: consumerMeesage.Content,
Timestamp: consumerMeesage.Timestamp,
},
Commit: func() error {
err := consumer.CommitMessages(ctx, msg)
if err != nil {
log.Error("Error committing kafka message", "error", err.Error())
return domain.ErrInternal
}
return nil
},
}
}
}

View File

@ -0,0 +1,73 @@
package kafka
import (
"context"
"encoding/json"
"time"
"github.com/IBM/sarama"
"github.com/aykhans/oh-my-chat/internal/adapter/config"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/google/uuid"
)
type MessageProducer struct {
saramaProducer *sarama.SyncProducer
}
func (producer *MessageProducer) ProduceMessage(
ctx context.Context,
message *domain.Message,
) error {
messageJSON, err := (&ProducerMessage{
UserID: message.UserID,
ChatID: message.ChatID,
Content: message.Content,
Timestamp: message.Timestamp,
}).JSON()
if err != nil {
return err
}
producerMessage := &sarama.ProducerMessage{
Topic: message.ChatID,
Value: sarama.StringEncoder(messageJSON),
Timestamp: message.Timestamp,
}
_, _, err = (*producer.saramaProducer).SendMessage(producerMessage)
if err != nil {
return err
}
return nil
}
type ProducerMessage struct {
UserID uuid.UUID `json:"user_id"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
func (message *ProducerMessage) JSON() ([]byte, error) {
return json.Marshal(message)
}
func NewMessageProducer(producerConfig *config.KafkaProducerConfig) (*MessageProducer, error) {
config := sarama.NewConfig()
config.Producer.Return.Successes = true // enable message delivery reports
config.Producer.RequiredAcks = sarama.WaitForAll // require all in-sync replicas to acknowledge the message
config.Producer.Retry.Max = 5 // number of retries before giving up on sending a message to a partition
config.Producer.Retry.Backoff = time.Second * 60 // time to wait between retries
config.Producer.Partitioner = sarama.NewRoundRobinPartitioner // walks through the available partitions one at a time
config.Producer.Compression = sarama.CompressionSnappy // compress messages using Snappy
config.Producer.Idempotent = true // producer will ensure that messages are successfully sent and acknowledged
config.Producer.Flush.Frequency = time.Millisecond * 20 // time to wait before sending a batch of messages
config.Producer.Flush.Bytes = 32 * 1024 // number of bytes to trigger a batch of messages
config.Net.MaxOpenRequests = 1
config.Metadata.AllowAutoTopicCreation = true
producer, err := sarama.NewSyncProducer(producerConfig.BootstrapServers, config)
if err != nil {
return nil, err
}
return &MessageProducer{&producer}, err
}

View File

@ -0,0 +1,16 @@
package domain
import "errors"
var (
ErrInternal = errors.New("internal error")
ErrConflictingData = errors.New("data conflicts with existing data in unique column")
ErrDataNotFound = errors.New("data not found")
ErrInvalidEmailCredentials = errors.New("invalid email or password")
ErrInvalidUsernameCredentials = errors.New("invalid username or password")
ErrTokenCreation = errors.New("error creating token")
ErrExpiredToken = errors.New("access token has expired")
ErrUsernameExists = errors.New("username already exists")
ErrEmailExists = errors.New("email already exists")
ErrInvalidToken = errors.New("invalid token")
)

View File

@ -0,0 +1,20 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type Message struct {
UserID uuid.UUID
ChatID string
Content string
Type string
Timestamp time.Time
}
type StreamMessage struct {
*Message
Commit func() error
}

View File

@ -0,0 +1,7 @@
package domain
import "github.com/google/uuid"
type AuthPayload struct {
UserID uuid.UUID
}

View File

@ -0,0 +1,14 @@
package domain
import (
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID
Username string
Email string
Password string
// CreatedAt time.Time
// UpdatedAt time.Time
}

View File

@ -0,0 +1,17 @@
package port
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
)
type TokenService interface {
CreateToken(user *domain.User) (string, error)
VerifyToken(token string) (*domain.AuthPayload, error)
}
type AuthService interface {
LoginByEmail(ctx context.Context, email, password string) (string, error)
LoginByUsername(ctx context.Context, username, password string) (string, error)
}

View File

@ -0,0 +1,26 @@
package port
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/google/uuid"
)
type MessageProducer interface {
ProduceMessage(ctx context.Context, message *domain.Message) error
}
type MessageConsumer interface {
ConsumeMessage(ctx context.Context, uid string, getChats func() []string, message chan<- *domain.StreamMessage) error
}
type MessageRepository interface {
CreateMessage(ctx context.Context, message *domain.Message) (*domain.Message, error)
}
type MessageService interface {
SendMessage(ctx context.Context, message *domain.Message) error
ReceiveMessage(ctx context.Context, userID uuid.UUID, message chan<- *domain.StreamMessage) error
CreateMessage(ctx context.Context, message *domain.Message) (*domain.Message, error)
}

View File

@ -0,0 +1,22 @@
package port
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
)
type UserRepository interface {
CreateUser(ctx context.Context, user *domain.User) (*domain.User, error)
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
GetUserByUsername(ctx context.Context, username string) (*domain.User, error)
IsUsernameExists(ctx context.Context, username string) (bool, error)
IsEmailExists(ctx context.Context, email string) (bool, error)
// GetUserByID(ctx context.Context, id uint64) (*domain.User, error)
// DeleteUser(ctx context.Context, id uint64) error
}
type UserService interface {
Register(ctx context.Context, user *domain.User) (*domain.User, error)
// GetUser(ctx context.Context, id uint64) (*domain.User, error)
// DeleteUser(ctx context.Context, id uint64) error
}

View File

@ -0,0 +1,72 @@
package service
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/aykhans/oh-my-chat/internal/core/utils"
)
type AuthService struct {
userRepository port.UserRepository
tokenService port.TokenService
}
// NewAuthService creates a new auth service instance
func NewAuthService(userRepository port.UserRepository, tokenService port.TokenService) *AuthService {
return &AuthService{
userRepository,
tokenService,
}
}
func (authService *AuthService) LoginByEmail(
ctx context.Context,
email, password string,
) (string, error) {
user, err := authService.userRepository.GetUserByEmail(ctx, email)
if err != nil {
if err == domain.ErrDataNotFound {
return "", domain.ErrInvalidEmailCredentials
}
return "", domain.ErrInternal
}
err = utils.ComparePassword(password, user.Password)
if err != nil {
return "", domain.ErrInvalidEmailCredentials
}
accessToken, err := authService.tokenService.CreateToken(user)
if err != nil {
return "", domain.ErrTokenCreation
}
return accessToken, nil
}
func (authService *AuthService) LoginByUsername(
ctx context.Context,
username, password string,
) (string, error) {
user, err := authService.userRepository.GetUserByEmail(ctx, username)
if err != nil {
if err == domain.ErrDataNotFound {
return "", domain.ErrInvalidUsernameCredentials
}
return "", domain.ErrInternal
}
err = utils.ComparePassword(password, user.Password)
if err != nil {
return "", domain.ErrInvalidUsernameCredentials
}
accessToken, err := authService.tokenService.CreateToken(user)
if err != nil {
return "", domain.ErrTokenCreation
}
return accessToken, nil
}

View File

@ -0,0 +1,55 @@
package service
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/google/uuid"
)
type MessageService struct {
producer port.MessageProducer
consumer port.MessageConsumer
repo port.MessageRepository
}
func NewMessageService(
producerService port.MessageProducer,
consumerService port.MessageConsumer,
repo port.MessageRepository,
) *MessageService {
return &MessageService{
producerService,
consumerService,
repo,
}
}
func (chatServie *MessageService) SendMessage(
ctx context.Context,
message *domain.Message,
) error {
message.ChatID = "chat_" + message.ChatID
return chatServie.producer.ProduceMessage(ctx, message)
}
func (chatServie *MessageService) ReceiveMessage(
ctx context.Context,
userID uuid.UUID,
message chan<- *domain.StreamMessage,
) error {
return chatServie.consumer.ConsumeMessage(
ctx,
userID.String(),
func() []string { return []string{"chat_1", "chat_5", "chat_9"} },
message,
)
}
func (chatServie *MessageService) CreateMessage(
ctx context.Context,
message *domain.Message,
) (*domain.Message, error) {
return chatServie.repo.CreateMessage(ctx, message)
}

View File

@ -0,0 +1,49 @@
package service
import (
"context"
"github.com/aykhans/oh-my-chat/internal/core/domain"
"github.com/aykhans/oh-my-chat/internal/core/port"
"github.com/aykhans/oh-my-chat/internal/core/utils"
)
type UserService struct {
repo port.UserRepository
}
func NewUserService(repo port.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (userService *UserService) Register(
ctx context.Context,
user *domain.User,
) (*domain.User, error) {
if exists, err := userService.repo.IsUsernameExists(ctx, user.Username); err != nil {
return nil, domain.ErrInternal
} else if exists {
return nil, domain.ErrUsernameExists
}
if exists, err := userService.repo.IsEmailExists(ctx, user.Email); err != nil {
return nil, domain.ErrInternal
} else if exists {
return nil, domain.ErrEmailExists
}
hashedPassword, err := utils.HashPassword(user.Password)
if err != nil {
return nil, domain.ErrInternal
}
user.Password = hashedPassword
user, err = userService.repo.CreateUser(ctx, user)
if err != nil {
if err == domain.ErrConflictingData {
return nil, err
}
return nil, domain.ErrInternal
}
return user, nil
}

View File

@ -0,0 +1,9 @@
package utils
import (
"strings"
)
func Str2StrSlice(value string) []string {
return strings.Split(strings.ReplaceAll(value, " ", ""), ",")
}

15
internal/core/utils/os.go Normal file
View File

@ -0,0 +1,15 @@
package utils
import "os"
func ExitErr() {
os.Exit(1)
}
func GetEnvOrDefault(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}

View File

@ -0,0 +1,26 @@
package utils
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword hashes password and returns hashed password or error
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
// ComparePassword compares password with hashed password and returns error if they don't match or nil if they do
func ComparePassword(password, hashedPassword string) error {
return bcrypt.CompareHashAndPassword(
[]byte(hashedPassword),
[]byte(password),
)
}

View File

@ -0,0 +1,7 @@
package utils
import "time"
func GetNow() time.Time {
return time.Now()
}