init: Initial commit

This commit is contained in:
Björn Benouarets
2026-01-21 06:35:35 +01:00
commit e13859a3ac
30 changed files with 1224 additions and 0 deletions

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.25.3-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"]

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# SecNex OAuth2 API
## Configuration
### Environment Variables
| Variable | Description | Default Value |
|----------|-------------|---------------|
| ENV | Environment | development |
| UNPROTECTED_ENDPOINTS | Unprotected endpoints | |
| DEBUG | Debug mode | false |
| FIBER_SHOW_STARTUP_MESSAGE | Show startup message | false |
| CORS_ALLOW_ORIGINS | CORS allow origins | * |
| CORS_ALLOW_HEADERS | CORS allow headers | Origin, Content-Type, Accept |
| CORS_ALLOW_METHODS | CORS allow methods | GET, POST, PUT, DELETE |
| ADDRESS | Address | :3000 |
| DATABASE_HOST | Database host | localhost |
| DATABASE_PORT | Database port | 5432 |
| DATABASE_USER | Database user | postgres |
| DATABASE_PASSWORD | Database password | postgres |
| DATABASE_NAME | Database name | secnex |
| REDIS_HOST | Redis host | localhost |
| REDIS_PORT | Redis port | 6379 |
| REDIS_PASSWORD | Redis password | |
| JWT_SECRET | JWT secret | your-256-bit-secret |
### Development Environment Variables
| Variable | Description | Default Value |
|----------|-------------|---------------|
| UNPROTECTED_ENDPOINTS | Unprotected endpoints | /api_keys |
## API Endpoints
### Create API Key
```bash
curl -X GET http://localhost:3000/api_keys
```
### Login
```bash
curl -X POST http://localhost:3000/login -d '{"username": "admin", "password": "admin"}'
```
### Register
```bash
curl -X POST http://localhost:3000/register -d '{"username": "admin", "password": "admin"}'
```
### Session Info
```bash
curl -X POST http://localhost:3000/session/info -d '{"token": "your-token"}'
```
### Create API Key
```bash
curl -X GET http://localhost:3000/api_keys
```
***Note:*** The API key can be created only in development environment without authentication. Use the header `Authorization: Bearer <api-key>` to authenticate the requests in other environments.

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

@@ -0,0 +1,52 @@
package cache
import (
"context"
"fmt"
"git.secnex.io/secnex/mgmt-api/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
}

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

@@ -0,0 +1,64 @@
package config
import (
"strings"
"git.secnex.io/secnex/mgmt-api/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
Environment string
UnprotectedEndpoints []string
}
var CONFIG *Config
func generateSecret() string {
return utils.GenerateRandomString(32)
}
func NewConfig() *Config {
Environment := utils.GetEnv("ENV", "development")
UnprotectedEndpoints := strings.Split(utils.GetEnv("UNPROTECTED_ENDPOINTS", ""), ",")
if Environment == "development" {
UnprotectedEndpoints = append(UnprotectedEndpoints, "/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", ""),
Environment: Environment,
UnprotectedEndpoints: UnprotectedEndpoints,
}
CONFIG = c
return c
}

View File

@@ -0,0 +1,43 @@
package controllers
import (
"time"
"git.secnex.io/secnex/mgmt-api/services"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/gofiber/fiber/v2"
)
type CreateApplicationRequest struct {
Name string `json:"name" validate:"required"`
TenantID string `json:"tenant" validate:"required"`
ExpiresIn int64 `json:"expires_in"`
}
func CreateApplicationController(c *fiber.Ctx) error {
var request CreateApplicationRequest
validateError, err := utils.ValidateRequest(c, &request)
if err != nil {
return validateError.Send(c)
}
var expiresAt *time.Time
if request.ExpiresIn > 0 {
expiresAtTime := time.Now().Add(time.Duration(request.ExpiresIn) * time.Second)
expiresAt = &expiresAtTime
}
response := services.CreateNewApplication(request.Name, request.TenantID, expiresAt)
return response.Send(c)
}
func GetApplicationController(c *fiber.Ctx) error {
return nil
}
func UpdateApplicationController(c *fiber.Ctx) error {
return nil
}
func DeleteApplicationController(c *fiber.Ctx) error {
return nil
}

22
app/controllers/tenant.go Normal file
View File

@@ -0,0 +1,22 @@
package controllers
import (
"git.secnex.io/secnex/mgmt-api/services"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/gofiber/fiber/v2"
)
type CreateTenantRequest struct {
Name string `json:"name" validate:"required"`
}
func CreateTenantController(c *fiber.Ctx) error {
var request CreateTenantRequest
validateError, err := utils.ValidateRequest(c, &request)
if err != nil {
return validateError.Send(c)
}
response := services.CreateTenant(request.Name)
return response.Send(c)
}

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

@@ -0,0 +1,102 @@
package database
import (
"fmt"
"strings"
"git.secnex.io/secnex/mgmt-api/config"
"git.secnex.io/secnex/mgmt-api/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
}

39
app/go.mod Normal file
View File

@@ -0,0 +1,39 @@
module git.secnex.io/secnex/mgmt-api
go 1.25.3
require (
git.secnex.io/secnex/masterlog v0.1.0
github.com/go-playground/validator/v10 v10.30.1
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.46.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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // 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/leodido/go-urn v1.4.0 // 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/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.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

87
app/go.sum Normal file
View File

@@ -0,0 +1,87 @@
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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=

73
app/main.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/mgmt-api/cache"
"git.secnex.io/secnex/mgmt-api/config"
"git.secnex.io/secnex/mgmt-api/controllers"
"git.secnex.io/secnex/mgmt-api/database"
"git.secnex.io/secnex/mgmt-api/middlewares"
"git.secnex.io/secnex/mgmt-api/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.User{},
&models.Tenant{},
&models.Application{},
}
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(middlewares.AuthMiddleware())
app.Use(cors.New(cors.Config{
AllowOrigins: config.CorsAllowOrigins,
AllowHeaders: config.CorsAllowHeaders,
AllowMethods: config.CorsAllowMethods,
}))
// Controllers
app.Post("/apps", controllers.CreateApplicationController)
app.Post("/tenants", controllers.CreateTenantController)
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
}
}

38
app/middlewares/auth.go Normal file
View File

@@ -0,0 +1,38 @@
package middlewares
import (
"slices"
"strings"
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/mgmt-api/config"
"github.com/gofiber/fiber/v2"
)
func AuthMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
if slices.Contains(config.CONFIG.UnprotectedEndpoints, c.Path()) {
masterlog.Debug("Unprotected endpoint", map[string]interface{}{"path": c.Path()})
return c.Next()
}
token := c.Get("Authorization")
if token == "" {
masterlog.Debug("No token provided", map[string]interface{}{"path": c.Path(), "authorization": c.Get("Authorization")})
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"})
}
tokenParts := strings.Split(token, " ")
if len(tokenParts) != 2 {
masterlog.Debug("Invalid token parts", map[string]interface{}{"token_parts": tokenParts})
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"})
}
tokenPartType, _ := tokenParts[0], tokenParts[1]
if tokenPartType != "Bearer" {
masterlog.Debug("Invalid token type", map[string]interface{}{"token_type": tokenPartType})
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"})
}
return c.Next()
}
}

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
}
}

41
app/models/application.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import (
"time"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Application struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;uniqueIndex:idx_name_tenant_id" json:"name"`
Secret string `gorm:"not null" json:"secret"`
TenantID uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_name_tenant_id" json:"tenant_id"`
ExpiresAt *time.Time `gorm:"not null" json:"expires_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant"`
Authorizations []Authorization `gorm:"foreignKey:ClientID" json:"authorizations"`
}
func (Application) TableName() string {
return "applications"
}
func (application *Application) BeforeCreate(tx *gorm.DB) (err error) {
secretHash, err := utils.Hash(application.Secret)
if err != nil {
return err
}
application.Secret = secretHash
if application.ExpiresAt == nil {
expiresAt := time.Now().Add(time.Hour * 24 * 365)
application.ExpiresAt = &expiresAt
}
return nil
}

View File

@@ -0,0 +1,40 @@
package models
import (
"time"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Authorization struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
ClientID uuid.UUID `gorm:"type:uuid;not null" json:"client_id"`
Code string `gorm:"not null" json:"code"`
ExpiresAt *time.Time `gorm:"not null" json:"expires_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
User *User `gorm:"foreignKey:UserID" json:"user"`
Application *Application `gorm:"foreignKey:ClientID" json:"application"`
}
func (Authorization) TableName() string {
return "authorizations"
}
func (authorization *Authorization) BeforeCreate(tx *gorm.DB) (err error) {
codeHash, err := utils.Hash(authorization.Code)
if err != nil {
return err
}
authorization.Code = codeHash
if authorization.ExpiresAt == nil {
expiresAt := time.Now().Add(time.Minute * 10)
authorization.ExpiresAt = &expiresAt
}
return nil
}

26
app/models/tenant.go Normal file
View File

@@ -0,0 +1,26 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Tenant struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;unique" json:"name"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
AllowSelfRegistration bool `gorm:"not null;default:false" json:"allow_self_registration"`
AllowSelfRegistrationDomains *[]string `gorm:"type:jsonb" json:"allow_self_registration_domains"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Users []User `gorm:"foreignKey:TenantID" json:"users"`
Applications []Application `gorm:"foreignKey:TenantID" json:"applications"`
}
func (Tenant) TableName() string {
return "tenants"
}

39
app/models/users.go Normal file
View File

@@ -0,0 +1,39 @@
package models
import (
"time"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
type User struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
FirstName string `gorm:"not null" json:"first_name"`
LastName string `gorm:"not null" json:"last_name"`
Username string `gorm:"not null;unique" json:"username"`
Password string `gorm:"not null" json:"password"`
Email string `gorm:"not null;unique" json:"email"`
Verified bool `gorm:"not null;default:false" json:"verified"`
TenantID *uuid.UUID `gorm:"type:uuid" json:"tenant_id"`
ExternalID *uuid.UUID `gorm:"type:uuid" 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"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant"`
}
func (User) TableName() string {
return "users"
}
func (user *User) BeforeCreate(tx *gorm.DB) (err error) {
passwordHash, err := utils.Hash(user.Password)
if err != nil {
return err
}
user.Password = passwordHash
return nil
}

View File

@@ -0,0 +1,20 @@
package repositories
import (
"time"
"git.secnex.io/secnex/mgmt-api/database"
"git.secnex.io/secnex/mgmt-api/models"
)
func CreateApplication(application *models.Application) error {
return database.DB.Create(application).Error
}
func GetApplicationByClientID(clientID string) (*models.Application, error) {
var application *models.Application
if err := database.DB.Where("client_id = ? AND expires_at > ?", clientID, time.Now().UTC()).First(&application).Error; err != nil {
return nil, err
}
return application, nil
}

View File

@@ -0,0 +1,29 @@
package repositories
import (
"git.secnex.io/secnex/mgmt-api/database"
"git.secnex.io/secnex/mgmt-api/models"
)
func CreateTenant(tenant *models.Tenant) error {
return database.DB.Create(tenant).Error
}
func UpsertTenant(tenant *models.Tenant) (*models.Tenant, error) {
createdOrFoundTenant := tenant
err := database.DB.
Where("id = ?", tenant.ID).
FirstOrCreate(createdOrFoundTenant).Error
if err != nil {
return nil, err
}
return createdOrFoundTenant, nil
}
func GetTenantByID(id string) (*models.Tenant, error) {
var tenant *models.Tenant
if err := database.DB.Where("id = ?", id).First(&tenant).Error; err != nil {
return nil, err
}
return tenant, nil
}

40
app/repositories/users.go Normal file
View File

@@ -0,0 +1,40 @@
package repositories
import (
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/mgmt-api/database"
"git.secnex.io/secnex/mgmt-api/models"
)
func GetUserByUsername(username string) (*models.User, error) {
var user *models.User
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return user, nil
}
func GetUserByUniqueFields(username, email string) (*models.User, error) {
var user *models.User
if err := database.DB.Where("username = ? OR email = ?", username, email).First(&user).Error; err != nil {
return nil, nil
}
return user, nil
}
func CreateUser(firstName, lastName, username, password, email string) error {
user := &models.User{
FirstName: firstName,
LastName: lastName,
Username: username,
Password: password,
Email: email,
}
if err := database.DB.Create(user).Error; err != nil {
masterlog.Debug("Failed to create user in database", map[string]interface{}{"error": err.Error(), "username": username, "email": email})
return err
}
masterlog.Debug("User created successfully", map[string]interface{}{"username": username, "email": email})
return nil
}

View File

@@ -0,0 +1,44 @@
package services
import (
"net/http"
"time"
"git.secnex.io/secnex/masterlog"
"git.secnex.io/secnex/mgmt-api/models"
"git.secnex.io/secnex/mgmt-api/repositories"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func CreateNewApplication(name string, tenantID string, expiresAt *time.Time) *utils.HTTPResponse {
tenant, err := repositories.GetTenantByID(tenantID)
if err != nil {
masterlog.Error("Failed to get tenant", map[string]interface{}{"error": err.Error()})
return utils.NewHTTPResponse(http.StatusInternalServerError, &fiber.Map{"message": "Failed to get tenant"}, "", nil, nil)
}
if tenant == nil {
return utils.NewHTTPResponse(http.StatusNotFound, &fiber.Map{"message": "Tenant not found"}, "", nil, nil)
}
applicationID := uuid.New()
applicationSecret := utils.GenerateRandomString(32)
application := &models.Application{
Name: name,
TenantID: tenant.ID,
ExpiresAt: expiresAt,
Secret: applicationSecret,
ID: applicationID,
}
if err := repositories.CreateApplication(application); err != nil {
masterlog.Error("Failed to create application", map[string]interface{}{"error": err.Error()})
if utils.IsDuplicateKeyError(err) {
return utils.NewHTTPResponse(http.StatusBadRequest, &fiber.Map{"message": "Application already exists"}, "", nil, nil)
}
return utils.NewHTTPResponse(http.StatusInternalServerError, &fiber.Map{"message": "Failed to create application"}, "", nil, nil)
}
return utils.NewHTTPResponse(http.StatusOK, &fiber.Map{"message": "Application created", "id": applicationID, "secret": applicationSecret}, "", nil, nil)
}

27
app/services/tenant.go Normal file
View File

@@ -0,0 +1,27 @@
package services
import (
"net/http"
"git.secnex.io/secnex/mgmt-api/models"
"git.secnex.io/secnex/mgmt-api/repositories"
"git.secnex.io/secnex/mgmt-api/utils"
"github.com/gofiber/fiber/v2"
)
func CreateTenant(name string) *utils.HTTPResponse {
tenant := &models.Tenant{
Name: name,
}
createdOrFoundTenant, err := repositories.UpsertTenant(tenant)
if err != nil {
if utils.IsDuplicateKeyError(err) {
return utils.NewHTTPResponse(http.StatusBadRequest, &fiber.Map{"message": "Tenant already exists"}, "", nil, nil)
}
return utils.NewHTTPResponse(http.StatusInternalServerError, &fiber.Map{"message": "Failed to create tenant"}, "", nil, nil)
}
if createdOrFoundTenant == nil {
return utils.NewHTTPResponse(http.StatusInternalServerError, &fiber.Map{"message": "Failed to create tenant"}, "", nil, nil)
}
return utils.NewHTTPResponse(http.StatusOK, &fiber.Map{"message": "Tenant created", "id": createdOrFoundTenant.ID, "name": createdOrFoundTenant.Name}, "", 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
}

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

@@ -0,0 +1,16 @@
package utils
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
func ValidateRequest(c *fiber.Ctx, request interface{}) (*HTTPResponse, error) {
if err := c.BodyParser(request); err != nil {
return NewHTTPResponse(fiber.StatusBadRequest, &fiber.Map{"message": "Invalid request body"}, "", nil, nil), err
}
if err := validator.New().Struct(request); err != nil {
return NewHTTPResponse(fiber.StatusBadRequest, &fiber.Map{"message": "Invalid request body"}, "", nil, nil), err
}
return NewHTTPResponse(fiber.StatusOK, nil, "", nil, nil), nil
}

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