init: Initial commit

This commit is contained in:
Björn Benouarets
2026-01-20 06:53:05 +01:00
commit fc8238759a
31 changed files with 1384 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
devTools/
*.log
CLAUDE.md

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.25.5-alpine AS builder
WORKDIR /app
COPY ./app/go.mod ./app/go.sum ./
RUN go mod download
RUN go mod verify
COPY ./app ./.
RUN go build -o app .
FROM alpine:latest AS runner
WORKDIR /app
COPY --from=builder /app/app /app/app
CMD ["./app"]

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# SecNex Taro Bot
**Taro Bot* by SecNex is a Microsoft Teams bot service to send messages to Microsoft Teams channels.

123
app/bot/auth.go Normal file
View File

@@ -0,0 +1,123 @@
package bot
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.secnex.io/secnex/masterlog"
)
var AUTH *Auth
type Token struct {
Data map[string]interface{}
ExpiresIn int `json:"expires_in"`
ExpiresAt time.Time `json:"expires_at"`
AccessToken string `json:"access_token"`
}
type Auth struct {
TenantId string
AppId string
AppSecret string
token *Token
}
func NewAuth(tenantId, appId, appSecret string) *Auth {
auth := &Auth{
TenantId: tenantId,
AppId: appId,
AppSecret: appSecret,
}
AUTH = auth
return auth
}
func (a *Auth) getRequestUrl() string {
return fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", a.TenantId)
}
func (a *Auth) requestToken() (*Token, error) {
if a.TenantId == "" {
return nil, fmt.Errorf("tenant ID is required but not set")
}
if a.AppId == "" {
return nil, fmt.Errorf("app ID is required but not set")
}
if a.AppSecret == "" {
return nil, fmt.Errorf("app secret is required but not set")
}
requestUrl := a.getRequestUrl()
masterlog.Debug("Requesting token", map[string]interface{}{
"url": requestUrl,
"tenantId": a.TenantId,
"appId": a.AppId,
})
body := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{a.AppId},
"client_secret": []string{a.AppSecret},
"scope": []string{"https://api.botframework.com/.default"},
}
req, err := http.NewRequest("POST", requestUrl, strings.NewReader(body.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
masterlog.Error("Token request failed", map[string]interface{}{
"statusCode": resp.StatusCode,
})
return nil, fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
masterlog.Debug("Token request successful", map[string]interface{}{
"statusCode": resp.StatusCode,
})
if len(bodyBytes) == 0 {
return nil, fmt.Errorf("empty response body from token endpoint")
}
var token Token
err = json.Unmarshal(bodyBytes, &token)
if err != nil {
masterlog.Error("Failed to unmarshal token response", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to unmarshal token response: %w", err)
}
token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
masterlog.Debug("Requested token", map[string]interface{}{"accessToken": token.AccessToken, "expiresIn": token.ExpiresIn, "expiresAt": token.ExpiresAt})
return &token, nil
}
func (a *Auth) GetToken() (string, error) {
if a.token == nil || a.token.ExpiresAt.Before(time.Now()) {
token, err := a.requestToken()
if err != nil {
masterlog.Error("Failed to refresh token", map[string]interface{}{"error": err.Error()})
return "", err
}
a.token = token
masterlog.Debug("Refreshed token", map[string]interface{}{"token": a.token.AccessToken})
}
return a.token.AccessToken, nil
}

56
app/bot/conversation.go Normal file
View File

@@ -0,0 +1,56 @@
package bot
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/taro-bot/config"
)
type Conversation struct {
auth Auth
}
func NewConversation(auth Auth) *Conversation {
return &Conversation{
auth: auth,
}
}
func (c *Conversation) SendMessage(message *Message) error {
token, err := c.auth.GetToken()
if err != nil {
return err
}
json, err := message.ToJSON()
if err != nil {
return err
}
trafficManagerUrl := config.CONFIG.MicrosoftTeamsBotUrl
requestUrl := fmt.Sprintf("%s/v3/conversations", trafficManagerUrl)
req, err := http.NewRequest("POST", requestUrl, strings.NewReader(json))
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
statusCode := resp.StatusCode
masterlog.Info("Sent message via Teams Bot successfully", map[string]interface{}{"statusCode": statusCode})
if statusCode >= 300 {
return errors.New(string(body))
}
return nil
}

53
app/bot/message.go Normal file
View File

@@ -0,0 +1,53 @@
package bot
import "encoding/json"
type Message struct {
IsGroup bool `json:"isGroup"`
ChannelData ChannelData `json:"channelData"`
Activity Activity `json:"activity"`
}
type ChannelData struct {
Channel Channel `json:"channel"`
}
type Channel struct {
ID string `json:"id"`
}
type Activity struct {
Type string `json:"type"`
Text string `json:"text"`
}
func NewMessage(isGroup bool, channelData ChannelData, activity Activity) *Message {
return &Message{
IsGroup: isGroup,
ChannelData: channelData,
Activity: activity,
}
}
func NewTextPost(channelId, text string) *Message {
return &Message{
IsGroup: true,
ChannelData: ChannelData{
Channel: Channel{
ID: channelId,
},
},
Activity: Activity{
Type: "message",
Text: text,
},
}
}
func (m *Message) ToJSON() (string, error) {
jsonBytes, err := json.Marshal(m)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}

52
app/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,52 @@
package cache
import (
"context"
"fmt"
"git.secnex.io/secnex/taro-bot/config"
"github.com/valkey-io/valkey-go"
)
type RedisConfiguration struct {
Host string
Port string
Password string
}
type RedisCache struct {
Client valkey.Client
Context context.Context
}
var Cache RedisCache
func NewRedisConfiguration(host, port, password string) *RedisConfiguration {
return &RedisConfiguration{
Host: host,
Port: port,
Password: password,
}
}
func NewRedisConfigurationFromConfig(config *config.Config) *RedisConfiguration {
return &RedisConfiguration{
Host: config.RedisHost,
Port: config.RedisPort,
Password: config.RedisPassword,
}
}
func Connect(config *config.Config) error {
client, err := valkey.NewClient(valkey.ClientOption{InitAddress: []string{fmt.Sprintf("%s:%s", config.RedisHost, config.RedisPort)}})
if err != nil {
return err
}
ctx := context.Background()
cache := &RedisCache{
Client: client,
Context: ctx,
}
Cache = *cache
return nil
}

72
app/config/config.go Normal file
View File

@@ -0,0 +1,72 @@
package config
import (
"strings"
"git.secnex.io/secnex/taro-bot/utils"
)
type Config struct {
Debug bool
FiberShowStartupMessage bool
CorsAllowOrigins string
CorsAllowHeaders string
CorsAllowMethods string
Address string
DatabaseHost string
DatabasePort string
DatabaseUser string
DatabasePassword string
DatabaseName string
RedisHost string
RedisPort string
RedisPassword string
JwtSecret string
ENV string
UnprotectedEndpoints []string
MicrosoftTeamsBotUrl string
MicrosoftTenantId string
MicrosoftAppId string
MicrosoftAppSecret string
}
var CONFIG *Config
func generateSecret() string {
return utils.GenerateRandomString(32)
}
func NewConfig() *Config {
ENV := utils.GetEnv("ENV", "development")
UNPROTECTED_ENDPOINTS := strings.Split(utils.GetEnv("UNPROTECTED_ENDPOINTS", ""), ",")
if ENV == "development" {
UNPROTECTED_ENDPOINTS = append(UNPROTECTED_ENDPOINTS, "/api_keys")
}
c := &Config{
Debug: utils.GetEnvBool("DEBUG", false),
FiberShowStartupMessage: utils.GetEnvBool("FIBER_SHOW_STARTUP_MESSAGE", false),
CorsAllowOrigins: utils.GetEnv("CORS_ALLOW_ORIGINS", "*"),
CorsAllowHeaders: utils.GetEnv("CORS_ALLOW_HEADERS", "Origin, Content-Type, Accept"),
CorsAllowMethods: utils.GetEnv("CORS_ALLOW_METHODS", "GET, POST, PUT, DELETE"),
Address: utils.GetEnv("ADDRESS", ":3000"),
DatabaseHost: utils.GetEnv("DATABASE_HOST", "localhost"),
DatabasePort: utils.GetEnv("DATABASE_PORT", "5432"),
DatabaseUser: utils.GetEnv("DATABASE_USER", "postgres"),
DatabasePassword: utils.GetEnv("DATABASE_PASSWORD", "postgres"),
DatabaseName: utils.GetEnv("DATABASE_NAME", "secnex"),
JwtSecret: utils.GetEnv("JWT_SECRET", "your-256-bit-secret"),
RedisHost: utils.GetEnv("REDIS_HOST", "localhost"),
RedisPort: utils.GetEnv("REDIS_PORT", "6379"),
RedisPassword: utils.GetEnv("REDIS_PASSWORD", ""),
ENV: ENV,
UnprotectedEndpoints: UNPROTECTED_ENDPOINTS,
MicrosoftTeamsBotUrl: utils.GetEnv("MICROSOFT_TEAMS_BOT_URL", "https://smba.trafficmanager.net/emea"),
MicrosoftTenantId: utils.GetEnv("MICROSOFT_TENANT_ID", ""),
MicrosoftAppId: utils.GetEnv("MICROSOFT_APP_ID", ""),
MicrosoftAppSecret: utils.GetEnv("MICROSOFT_APP_SECRET", ""),
}
CONFIG = c
return c
}

119
app/controllers/message.go Normal file
View File

@@ -0,0 +1,119 @@
package controllers
import (
"encoding/json"
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/taro-bot/repositories"
"git.secnex.io/secnex/taro-bot/utils"
"github.com/gofiber/fiber/v2"
)
type MessageRequest struct {
EventType string `json:"type"`
ChannelID string `json:"channelId"`
Text string `json:"text"`
Recipient Recipient `json:"recipient"`
MembersAdded []Member `json:"membersAdded"`
MembersRemoved []Member `json:"membersRemoved"`
ChannelData ChannelData `json:"channelData"`
}
type Member struct {
ID string `json:"id"`
}
type Recipient struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ChannelData struct {
TeamsTeamID string `json:"teamsTeamId"`
TeamsChannelID string `json:"teamsChannelId"`
Tenant Tenant `json:"tenant"`
Team Team `json:"team"`
EventType string `json:"eventType"`
Settings Settings `json:"settings"`
}
type Tenant struct {
ID string `json:"id"`
}
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
AADGroupId string `json:"aadGroupId"`
}
type Settings struct {
SelectedChannel SelectedChannel `json:"selectedChannel"`
}
type SelectedChannel struct {
ID string `json:"id"`
}
func MessageController(c *fiber.Ctx) error {
var request MessageRequest
if err := json.Unmarshal(c.Body(), &request); err != nil {
return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{
"message": "Invalid request body",
}).Send(c)
}
masterlog.Debug("Message received", map[string]interface{}{"type": request.EventType, "text": request.Text, "channelId": request.ChannelID, "recipient": request.Recipient.ID, "membersAdded": len(request.MembersAdded), "membersRemoved": len(request.MembersRemoved)})
tenantId := request.ChannelData.Tenant.ID
teamId := request.ChannelData.Team.ID
if request.ChannelID == "emulator" {
tenantId = "4ef9262f-f8db-453d-be35-920132ca874d"
teamId = "405148b9-752f-4c29-867c-4670081382e1"
}
switch request.EventType {
case "message":
message := request.Text
masterlog.Debug("Message received", map[string]interface{}{"message": message})
case "conversationUpdate":
botIsAdded := false
botIsRemoved := false
for _, member := range request.MembersAdded {
if member.ID == request.Recipient.ID {
botIsAdded = true
break
}
}
for _, member := range request.MembersRemoved {
if member.ID == request.Recipient.ID {
botIsRemoved = true
break
}
}
if botIsAdded {
masterlog.Debug("Bot added to conversation", map[string]interface{}{"channelId": request.ChannelID, "tenantId": tenantId, "teamId": teamId, "eventType": request.ChannelData.EventType, "selectedChannelId": request.ChannelData.Settings.SelectedChannel.ID})
channel, err := repositories.UpsertChannel(request.ChannelData.Team.Name, tenantId, teamId)
if err != nil {
masterlog.Error("Failed to create team", map[string]interface{}{"error": err})
}
masterlog.Debug("Channel created", map[string]interface{}{"channelId": channel.ID})
}
if botIsRemoved {
err := repositories.DeleteTeamByExternalID(teamId)
if err != nil {
masterlog.Error("Failed to delete team", map[string]interface{}{"error": err})
}
masterlog.Debug("Team deleted", map[string]interface{}{"teamId": teamId})
}
}
return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{
"message": "Message received",
}, "", nil, nil).Send(c)
}
func MessageHeadController(c *fiber.Ctx) error {
masterlog.Debug("Message head received")
return utils.NewHTTPResponse(fiber.StatusNoContent, nil, "", nil, nil).Send(c)
}

View File

@@ -0,0 +1,54 @@
package controllers
import (
"git.secnex.io/secnex/taro-bot/repositories"
"git.secnex.io/secnex/taro-bot/services"
"git.secnex.io/secnex/taro-bot/utils"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func WebhookReceiveController(c *fiber.Ctx) error {
id := c.Params("id")
if id == "" {
return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{
"message": "ID is required",
}).Send(c)
}
token := c.Query("token")
if token == "" {
return utils.NewErrorResponse(fiber.StatusUnauthorized, &fiber.Map{
"message": "Token is required",
}).Send(c)
}
webhook, err := repositories.GetWebhookByID(uuid.MustParse(id))
if err != nil {
return utils.NewErrorResponse(fiber.StatusInternalServerError, &fiber.Map{
"message": "Failed to get webhook",
}).Send(c)
}
hashedToken := webhook.Token
valid, err := utils.Verify(token, hashedToken)
if err != nil {
return utils.NewErrorResponse(fiber.StatusInternalServerError, &fiber.Map{
"message": "Failed to verify token",
}).Send(c)
}
if !valid {
return utils.NewErrorResponse(fiber.StatusUnauthorized, &fiber.Map{
"message": "Invalid token",
}).Send(c)
}
var body utils.HTTPBody
if err := c.BodyParser(&body); err != nil {
return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{
"message": "Invalid request body",
}).Send(c)
}
return services.ExecuteWebhook(webhook.ChannelID, body).Send(c)
}

102
app/database/conn.go Normal file
View File

@@ -0,0 +1,102 @@
package database
import (
"fmt"
"strings"
"git.secnex.io/secnex/taro-bot/config"
"git.secnex.io/secnex/taro-bot/utils"
"git.secnex.io/secnex/masterlog"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type DatabaseConfiguration struct {
Host string
Port string
User string
Password string
Database string
}
var DB *gorm.DB
func NewDatabaseConfiguration(host, port, user, password, database string) *DatabaseConfiguration {
return &DatabaseConfiguration{
Host: host,
Port: port,
User: user,
Password: password,
Database: database,
}
}
func NewDatabaseConfigurationFromConfig(config *config.Config) *DatabaseConfiguration {
return &DatabaseConfiguration{
Host: config.DatabaseHost,
Port: config.DatabasePort,
User: config.DatabaseUser,
Password: config.DatabasePassword,
Database: config.DatabaseName,
}
}
func NewDatabaseConfigurationFromEnv() *DatabaseConfiguration {
return &DatabaseConfiguration{
Host: utils.GetEnv("DB_HOST", "localhost"),
Port: utils.GetEnv("DB_PORT", "5432"),
User: utils.GetEnv("DB_USER", "postgres"),
Password: utils.GetEnv("DB_PASSWORD", "postgres"),
Database: utils.GetEnv("DB_DATABASE", "secnex"),
}
}
func (c *DatabaseConfiguration) String() string {
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8", c.Host, c.Port, c.User, c.Password, c.Database)
}
func (c *DatabaseConfiguration) Connect(config *config.Config, models ...interface{}) error {
logLevel := logger.Silent
if config.Debug {
logLevel = logger.Info
}
db, err := gorm.Open(postgres.Open(c.String()), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return err
}
if err := AutoMigrate(db, models...); err != nil {
return err
}
DB = db
return nil
}
type SchemaProvider interface {
Schema() string
}
func AutoMigrate(conn *gorm.DB, models ...interface{}) error {
masterlog.Debug("Starting database auto-migration", map[string]interface{}{"model_count": len(models)})
err := conn.AutoMigrate(models...)
if err != nil {
// Check if error is about constraint that doesn't exist
// This can happen when GORM tries to drop old constraints during migration
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "constraint") {
masterlog.Debug("Ignoring constraint drop error", map[string]interface{}{"error": err.Error()})
// Return nil to continue despite the constraint error
return nil
}
masterlog.Debug("Auto-migration failed", map[string]interface{}{"error": err.Error()})
return err
}
masterlog.Debug("Database auto-migration completed successfully", map[string]interface{}{"model_count": len(models)})
return nil
}

13
app/database/exec.go Normal file
View File

@@ -0,0 +1,13 @@
package database
import "git.secnex.io/secnex/masterlog"
func Execute(query string) error {
masterlog.Debug("Executing database query", map[string]interface{}{"query": query})
if err := DB.Exec(query).Error; err != nil {
masterlog.Debug("Database query execution failed", map[string]interface{}{"error": err.Error(), "query": query})
return err
}
masterlog.Debug("Database query executed successfully", map[string]interface{}{"query": query})
return nil
}

35
app/go.mod Normal file
View File

@@ -0,0 +1,35 @@
module git.secnex.io/secnex/taro-bot
go 1.25.5
require (
git.secnex.io/secnex/masterlog v0.1.0
github.com/gofiber/fiber/v2 v2.52.10
github.com/google/uuid v1.6.0
github.com/valkey-io/valkey-go v1.0.70
golang.org/x/crypto v0.47.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
)

75
app/go.sum Normal file
View File

@@ -0,0 +1,75 @@
git.secnex.io/secnex/masterlog v0.1.0 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E=
git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valkey-io/valkey-go v1.0.70 h1:mjYNT8qiazxDAJ0QNQ8twWT/YFOkOoRd40ERV2mB49Y=
github.com/valkey-io/valkey-go v1.0.70/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

75
app/main.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/taro-bot/bot"
"git.secnex.io/secnex/taro-bot/cache"
"git.secnex.io/secnex/taro-bot/config"
"git.secnex.io/secnex/taro-bot/controllers"
"git.secnex.io/secnex/taro-bot/database"
"git.secnex.io/secnex/taro-bot/middlewares"
"git.secnex.io/secnex/taro-bot/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
)
func main() {
config := config.NewConfig()
pseudonymizer := masterlog.NewPseudonymizerFromString("1234567890")
masterlog.SetPseudonymizer(pseudonymizer)
masterlog.AddSensitiveFields("password", "token", "email", "token_value", "key_value")
if config.Debug {
masterlog.SetLevel(masterlog.LevelDebug)
} else {
masterlog.SetLevel(masterlog.LevelInfo)
}
masterlog.AddEncoder(&masterlog.JSONEncoder{})
allModels := []interface{}{
&models.Service{},
&models.Channel{},
&models.Webhook{},
}
dbConfig := database.NewDatabaseConfigurationFromConfig(config)
masterlog.Info("Connecting to database", map[string]interface{}{"host": config.DatabaseHost, "port": config.DatabasePort, "database": config.DatabaseName})
if err := dbConfig.Connect(config, allModels...); err != nil {
masterlog.Error("failed to connect to database", map[string]interface{}{"error": err.Error()})
return
}
masterlog.Info("Connected to database!")
masterlog.Info("Connecting to Redis", map[string]interface{}{"host": config.RedisHost, "port": config.RedisPort})
if err := cache.Connect(config); err != nil {
masterlog.Error("failed to connect to Redis", map[string]interface{}{"error": err.Error()})
return
}
masterlog.Info("Connected to Redis!")
app := fiber.New(fiber.Config{
DisableStartupMessage: !config.FiberShowStartupMessage,
})
app.Use(middlewares.RequestLogger())
app.Use(cors.New(cors.Config{
AllowOrigins: config.CorsAllowOrigins,
AllowHeaders: config.CorsAllowHeaders,
AllowMethods: config.CorsAllowMethods,
}))
app.Head("/messages", controllers.MessageHeadController)
app.Post("/messages", controllers.MessageController)
app.Post("/webhooks/:id/receive", controllers.WebhookReceiveController)
bot.NewAuth(config.MicrosoftTenantId, config.MicrosoftAppId, config.MicrosoftAppSecret)
masterlog.Info("Starting server", map[string]interface{}{"address": config.Address})
if err := app.Listen(config.Address); err != nil {
masterlog.Error("failed to start server", map[string]interface{}{"error": err.Error()})
return
}
}

49
app/middlewares/log.go Normal file
View File

@@ -0,0 +1,49 @@
package middlewares
import (
"time"
"git.secnex.io/secnex/masterlog"
"github.com/gofiber/fiber/v2"
)
// RequestLogger logs incoming HTTP requests via masterlog.
func RequestLogger() fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start)
entry := map[string]interface{}{
"method": c.Method(),
"path": c.OriginalURL(),
"ip": c.IP(),
"duration": duration.String(),
"status": c.Response().StatusCode(),
"user_agent": c.Get("User-Agent"),
}
if err != nil {
entry["error"] = err.Error()
masterlog.Error(
"HTTP request failed",
entry,
)
return err
}
if status := c.Response().StatusCode(); status >= fiber.StatusInternalServerError {
masterlog.Error(
"HTTP request failed",
entry,
)
} else {
masterlog.Info(
"HTTP request successful",
entry,
)
}
return nil
}
}

24
app/models/channel.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Channel struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null" json:"name"`
TenantID uuid.UUID `gorm:"not null" json:"tenant_id"`
ExternalID string `gorm:"not null" json:"external_id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Webhooks []Webhook `gorm:"foreignKey:ChannelID" json:"webhooks"`
}
func (Channel) TableName() string {
return "channels"
}

24
app/models/service.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Service struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;unique" json:"name"`
Description string `gorm:"not null" json:"description"`
URL *string `json:"url"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Webhooks []Webhook `gorm:"foreignKey:ServiceID" json:"webhooks"`
}
func (Service) TableName() string {
return "services"
}

35
app/models/webhook.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import (
"time"
"git.secnex.io/secnex/taro-bot/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Webhook struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ServiceID uuid.UUID `gorm:"not null" json:"service_id"`
Token string `gorm:"not null" json:"token"`
ChannelID uuid.UUID `gorm:"not null" json:"channel_id"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Service *Service `gorm:"foreignKey:ServiceID" json:"service"`
Channel *Channel `gorm:"foreignKey:ChannelID" json:"channel"`
}
func (Webhook) TableName() string {
return "webhooks"
}
func (webhook *Webhook) BeforeCreate(tx *gorm.DB) (err error) {
tokenHash, err := utils.Hash(webhook.Token)
if err != nil {
return err
}
webhook.Token = tokenHash
return nil
}

View File

@@ -0,0 +1,58 @@
package repositories
import (
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/taro-bot/database"
"git.secnex.io/secnex/taro-bot/models"
"github.com/google/uuid"
)
func CreateChannel(name, tenantId, externalId string) error {
channel := &models.Channel{
Name: name,
TenantID: uuid.MustParse(tenantId),
ExternalID: externalId,
}
return database.DB.Create(channel).Error
}
func UpsertChannel(name, tenantId, externalId string) (*models.Channel, error) {
masterlog.Debug("Upserting channel", map[string]interface{}{"name": name, "tenantId": tenantId, "externalId": externalId})
channel := &models.Channel{}
tenantUUID := uuid.MustParse(tenantId)
masterlog.Debug("Upserting channel", map[string]interface{}{"name": name, "tenantId": tenantUUID, "externalId": externalId})
err := database.DB.
Where("external_id = ?", externalId).
FirstOrCreate(&channel, models.Channel{
Name: name,
TenantID: tenantUUID,
ExternalID: externalId,
}).Error
if err != nil {
return nil, err
}
return channel, nil
}
func GetChannelByID(id string) (*models.Channel, error) {
var channel models.Channel
if err := database.DB.Where("id = ?", id).First(&channel).Error; err != nil {
return nil, err
}
return &channel, nil
}
func GetChannelByExternalID(id string) (*models.Channel, error) {
var channel models.Channel
if err := database.DB.Where("external_id = ?", id).First(&channel).Error; err != nil {
return nil, err
}
return &channel, nil
}
func DeleteTeamByExternalID(id string) error {
return database.DB.Where("external_id = ?", id).Delete(&models.Channel{}).Error
}

View File

@@ -0,0 +1,24 @@
package repositories
import (
"git.secnex.io/secnex/taro-bot/database"
"git.secnex.io/secnex/taro-bot/models"
"github.com/google/uuid"
)
func CreateService(name, description string, url *string) error {
service := &models.Service{
Name: name,
Description: description,
URL: url,
}
return database.DB.Create(service).Error
}
func GetService(id uuid.UUID) (*models.Service, error) {
var service models.Service
if err := database.DB.Where("id = ?", id).First(&service).Error; err != nil {
return nil, err
}
return &service, nil
}

View File

@@ -0,0 +1,44 @@
package repositories
import (
"git.secnex.io/secnex/taro-bot/database"
"git.secnex.io/secnex/taro-bot/models"
"github.com/google/uuid"
)
func CreateWebhook(serviceId, channelId uuid.UUID, token string) (*models.Webhook, error) {
webhook := &models.Webhook{
ServiceID: serviceId,
ChannelID: channelId,
Token: token,
}
err := database.DB.Create(webhook).Error
if err != nil {
return nil, err
}
return webhook, nil
}
func GetWebhookByID(id uuid.UUID) (*models.Webhook, error) {
var webhook models.Webhook
if err := database.DB.Where("id = ?", id).First(&webhook).Error; err != nil {
return nil, err
}
return &webhook, nil
}
func GetWebhookByServiceID(serviceId uuid.UUID) (*models.Webhook, error) {
var webhook models.Webhook
if err := database.DB.Where("service_id = ?", serviceId).First(&webhook).Error; err != nil {
return nil, err
}
return &webhook, nil
}
func GetWebhookByChannelID(channelId uuid.UUID) (*models.Webhook, error) {
var webhook models.Webhook
if err := database.DB.Where("channel_id = ?", channelId).First(&webhook).Error; err != nil {
return nil, err
}
return &webhook, nil
}

View File

@@ -0,0 +1,32 @@
package services
import (
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/taro-bot/bot"
"git.secnex.io/secnex/taro-bot/repositories"
"git.secnex.io/secnex/taro-bot/utils"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func ExecuteWebhook(channelId uuid.UUID, body utils.HTTPBody) *utils.HTTPResponse {
channel, err := repositories.GetChannelByID(channelId.String())
if err != nil {
return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{
"message": "Failed to get channel",
}, "", nil, nil)
}
text := "New webhook received!"
conversation := bot.NewConversation(*bot.AUTH)
err = conversation.SendMessage(bot.NewTextPost(channel.ExternalID, text))
if err != nil {
return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{
"message": "Failed to send message",
}, "", nil, nil)
}
masterlog.Info("Webhook executed successfully", map[string]interface{}{"channelId": channelId, "text": text})
return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{
"message": "Webhook executed successfully",
}, "", nil, nil)
}

22
app/utils/env.go Normal file
View File

@@ -0,0 +1,22 @@
package utils
import (
"os"
"strings"
)
func GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func GetEnvBool(key string, defaultValue bool) bool {
value := strings.ToLower(GetEnv(key, ""))
if value == "" {
return defaultValue
}
return value == "true" || value == "1"
}

81
app/utils/hash.go Normal file
View File

@@ -0,0 +1,81 @@
package utils
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024
argon2Threads = 4
argon2KeyLen = 32
saltLength = 16
)
func Hash(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Threads, b64Salt, b64Hash)
return encodedHash, nil
}
func Verify(password, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return false, fmt.Errorf("invalid hash format")
}
if parts[1] != "argon2id" {
return false, fmt.Errorf("unsupported hash algorithm")
}
var version int
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return false, err
}
if version != argon2.Version {
return false, fmt.Errorf("incompatible version")
}
var memory, time uint32
var threads uint8
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
otherHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return true, nil
}
return false, nil
}

3
app/utils/http.go Normal file
View File

@@ -0,0 +1,3 @@
package utils
type HTTPBody map[string]interface{}

11
app/utils/res/http.go Normal file
View File

@@ -0,0 +1,11 @@
package res
import "github.com/gofiber/fiber/v2"
type HTTPResponse struct {
Code int `json:"code"`
Body *fiber.Map `json:"body"`
ODataContext string `json:"@odata.context"`
ODataCount int `json:"@odata.count"`
ODataNextLink *string `json:"@odata.nextLink"`
}

77
app/utils/response.go Normal file
View File

@@ -0,0 +1,77 @@
package utils
import (
"github.com/gofiber/fiber/v2"
)
type Response interface {
JSON() map[string]interface{}
Send(c *fiber.Ctx) error
}
type HTTPResponse struct {
Code int `json:"code"`
Body *fiber.Map `json:"body"`
ODataContext string `json:"@odata.context"`
ODataCount *int `json:"@odata.count"`
ODataNextLink *string `json:"@odata.nextLink"`
}
type ErrorResponse struct {
Code int `json:"code"`
Body *fiber.Map `json:"body"`
}
func NewHTTPResponse(code int, body *fiber.Map, oDataContext string, oDataCount *int, oDataNextLink *string) *HTTPResponse {
return &HTTPResponse{
Code: code,
Body: body,
ODataContext: oDataContext,
ODataCount: oDataCount,
ODataNextLink: oDataNextLink,
}
}
func NewErrorResponse(code int, body *fiber.Map) *ErrorResponse {
return &ErrorResponse{
Code: code,
Body: body,
}
}
func (res *HTTPResponse) JSON() map[string]interface{} {
result := map[string]interface{}{
"code": res.Code,
}
if res.Body != nil {
result["body"] = res.Body
}
if res.ODataContext != "" {
result["@odata.context"] = res.ODataContext
}
if res.ODataCount != nil {
result["@odata.count"] = *res.ODataCount
}
if res.ODataNextLink != nil {
result["@odata.nextLink"] = res.ODataNextLink
}
return result
}
func (res *ErrorResponse) JSON() map[string]interface{} {
result := map[string]interface{}{
"code": res.Code,
}
if res.Body != nil {
result["body"] = res.Body
}
return result
}
func (res *ErrorResponse) Send(c *fiber.Ctx) error {
return c.Status(res.Code).JSON(res.JSON())
}
func (res *HTTPResponse) Send(c *fiber.Ctx) error {
return c.Status(res.Code).JSON(res.JSON())
}

25
app/utils/sql.go Normal file
View File

@@ -0,0 +1,25 @@
package utils
import (
"errors"
"strings"
"gorm.io/gorm"
)
// IsDuplicateKeyError checks if an error is a duplicate key constraint violation
func IsDuplicateKeyError(err error) bool {
if err == nil {
return false
}
// Check for GORM duplicate key error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
// Check for PostgreSQL duplicate key error (SQLSTATE 23505)
errMsg := strings.ToLower(err.Error())
return strings.Contains(errMsg, "duplicate key value violates unique constraint") ||
strings.Contains(errMsg, "sqlstate 23505")
}

18
app/utils/token.go Normal file
View File

@@ -0,0 +1,18 @@
package utils
import (
"crypto/rand"
)
func GenerateRandomString(length int) string {
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return ""
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return string(b)
}

0
docker-compose.yml Normal file
View File