From fc8238759aa9bf0e689d2dc0cd67810cf4be6dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Benouarets?= Date: Tue, 20 Jan 2026 06:53:05 +0100 Subject: [PATCH] init: Initial commit --- .gitignore | 4 + Dockerfile | 21 +++++ README.md | 3 + app/bot/auth.go | 123 +++++++++++++++++++++++++++++ app/bot/conversation.go | 56 +++++++++++++ app/bot/message.go | 53 +++++++++++++ app/cache/redis.go | 52 ++++++++++++ app/config/config.go | 72 +++++++++++++++++ app/controllers/message.go | 119 ++++++++++++++++++++++++++++ app/controllers/webhook_receive.go | 54 +++++++++++++ app/database/conn.go | 102 ++++++++++++++++++++++++ app/database/exec.go | 13 +++ app/go.mod | 35 ++++++++ app/go.sum | 75 ++++++++++++++++++ app/main.go | 75 ++++++++++++++++++ app/middlewares/log.go | 49 ++++++++++++ app/models/channel.go | 24 ++++++ app/models/service.go | 24 ++++++ app/models/webhook.go | 35 ++++++++ app/repositories/channel.go | 58 ++++++++++++++ app/repositories/service.go | 24 ++++++ app/repositories/webhook.go | 44 +++++++++++ app/services/webhook_execute.go | 32 ++++++++ app/utils/env.go | 22 ++++++ app/utils/hash.go | 81 +++++++++++++++++++ app/utils/http.go | 3 + app/utils/res/http.go | 11 +++ app/utils/response.go | 77 ++++++++++++++++++ app/utils/sql.go | 25 ++++++ app/utils/token.go | 18 +++++ docker-compose.yml | 0 31 files changed, 1384 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/bot/auth.go create mode 100644 app/bot/conversation.go create mode 100644 app/bot/message.go create mode 100644 app/cache/redis.go create mode 100644 app/config/config.go create mode 100644 app/controllers/message.go create mode 100644 app/controllers/webhook_receive.go create mode 100644 app/database/conn.go create mode 100644 app/database/exec.go create mode 100644 app/go.mod create mode 100644 app/go.sum create mode 100644 app/main.go create mode 100644 app/middlewares/log.go create mode 100644 app/models/channel.go create mode 100644 app/models/service.go create mode 100644 app/models/webhook.go create mode 100644 app/repositories/channel.go create mode 100644 app/repositories/service.go create mode 100644 app/repositories/webhook.go create mode 100644 app/services/webhook_execute.go create mode 100644 app/utils/env.go create mode 100644 app/utils/hash.go create mode 100644 app/utils/http.go create mode 100644 app/utils/res/http.go create mode 100644 app/utils/response.go create mode 100644 app/utils/sql.go create mode 100644 app/utils/token.go create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83d598d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +devTools/ +*.log +CLAUDE.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19d59c9 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..af3e9bf --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# SecNex Taro Bot + +**Taro Bot* by SecNex is a Microsoft Teams bot service to send messages to Microsoft Teams channels. \ No newline at end of file diff --git a/app/bot/auth.go b/app/bot/auth.go new file mode 100644 index 0000000..5889235 --- /dev/null +++ b/app/bot/auth.go @@ -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 +} diff --git a/app/bot/conversation.go b/app/bot/conversation.go new file mode 100644 index 0000000..4d5e820 --- /dev/null +++ b/app/bot/conversation.go @@ -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 +} diff --git a/app/bot/message.go b/app/bot/message.go new file mode 100644 index 0000000..0d0e700 --- /dev/null +++ b/app/bot/message.go @@ -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 +} diff --git a/app/cache/redis.go b/app/cache/redis.go new file mode 100644 index 0000000..8ffe71e --- /dev/null +++ b/app/cache/redis.go @@ -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 +} diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000..92d5bdf --- /dev/null +++ b/app/config/config.go @@ -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 +} diff --git a/app/controllers/message.go b/app/controllers/message.go new file mode 100644 index 0000000..96d744c --- /dev/null +++ b/app/controllers/message.go @@ -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) +} diff --git a/app/controllers/webhook_receive.go b/app/controllers/webhook_receive.go new file mode 100644 index 0000000..2534d6b --- /dev/null +++ b/app/controllers/webhook_receive.go @@ -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) +} diff --git a/app/database/conn.go b/app/database/conn.go new file mode 100644 index 0000000..6e239af --- /dev/null +++ b/app/database/conn.go @@ -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 +} diff --git a/app/database/exec.go b/app/database/exec.go new file mode 100644 index 0000000..aa29b08 --- /dev/null +++ b/app/database/exec.go @@ -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 +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..e6305f8 --- /dev/null +++ b/app/go.mod @@ -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 +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..3d8dfd9 --- /dev/null +++ b/app/go.sum @@ -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= diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..7739b05 --- /dev/null +++ b/app/main.go @@ -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 + } +} diff --git a/app/middlewares/log.go b/app/middlewares/log.go new file mode 100644 index 0000000..01eaf15 --- /dev/null +++ b/app/middlewares/log.go @@ -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 + } +} diff --git a/app/models/channel.go b/app/models/channel.go new file mode 100644 index 0000000..2573b5f --- /dev/null +++ b/app/models/channel.go @@ -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" +} diff --git a/app/models/service.go b/app/models/service.go new file mode 100644 index 0000000..8f771f1 --- /dev/null +++ b/app/models/service.go @@ -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" +} diff --git a/app/models/webhook.go b/app/models/webhook.go new file mode 100644 index 0000000..d091edc --- /dev/null +++ b/app/models/webhook.go @@ -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 +} diff --git a/app/repositories/channel.go b/app/repositories/channel.go new file mode 100644 index 0000000..e5f4ce7 --- /dev/null +++ b/app/repositories/channel.go @@ -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 +} diff --git a/app/repositories/service.go b/app/repositories/service.go new file mode 100644 index 0000000..c59b986 --- /dev/null +++ b/app/repositories/service.go @@ -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 +} diff --git a/app/repositories/webhook.go b/app/repositories/webhook.go new file mode 100644 index 0000000..caea07d --- /dev/null +++ b/app/repositories/webhook.go @@ -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 +} diff --git a/app/services/webhook_execute.go b/app/services/webhook_execute.go new file mode 100644 index 0000000..f3a1c7f --- /dev/null +++ b/app/services/webhook_execute.go @@ -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) +} diff --git a/app/utils/env.go b/app/utils/env.go new file mode 100644 index 0000000..c2e46da --- /dev/null +++ b/app/utils/env.go @@ -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" +} diff --git a/app/utils/hash.go b/app/utils/hash.go new file mode 100644 index 0000000..3a10d88 --- /dev/null +++ b/app/utils/hash.go @@ -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 +} diff --git a/app/utils/http.go b/app/utils/http.go new file mode 100644 index 0000000..93d7600 --- /dev/null +++ b/app/utils/http.go @@ -0,0 +1,3 @@ +package utils + +type HTTPBody map[string]interface{} diff --git a/app/utils/res/http.go b/app/utils/res/http.go new file mode 100644 index 0000000..a5c0574 --- /dev/null +++ b/app/utils/res/http.go @@ -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"` +} diff --git a/app/utils/response.go b/app/utils/response.go new file mode 100644 index 0000000..18ef826 --- /dev/null +++ b/app/utils/response.go @@ -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()) +} diff --git a/app/utils/sql.go b/app/utils/sql.go new file mode 100644 index 0000000..90fd87c --- /dev/null +++ b/app/utils/sql.go @@ -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") +} diff --git a/app/utils/token.go b/app/utils/token.go new file mode 100644 index 0000000..7e42032 --- /dev/null +++ b/app/utils/token.go @@ -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) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e69de29