feat: migrate to DragonflyDB and clean up environment configuration

- Replace Redis with DragonflyDB for better performance and memory efficiency
- Remove redundant environment variables (POSTGRES_*, ENCRYPTION_KEY, OAUTH_SERVICE_URL)
- Consolidate database configuration to use single DB_* variables
- Use JWT_SECRET for both JWT tokens and encryption
- Remove PORT variable redundancy, use BACKEND_PORT consistently
- Clean up docker-compose configurations for dev/prod consistency
- Add DragonflyDB configuration with optimized memory usage
- Remove redis.conf as it's no longer needed
- Update health checks to use Redis-compatible CLI for DragonflyDB
- Add missing VITE_API_URL to production frontend
- Fix GitHub Actions to use correct go.sum path
- Clean up development directories and unused files
This commit is contained in:
Tomas Dvorak
2026-03-05 23:51:34 +01:00
parent f3a835caa2
commit 954a1a1080
146 changed files with 5801 additions and 25847 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
@@ -27,6 +27,9 @@ WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/main .
# Copy migrations directory
COPY --from=builder /app/migrations ./migrations
# Create necessary directories
RUN mkdir -p /app/uploads /data
+27 -10
View File
@@ -2,12 +2,12 @@ package config
import (
"fmt"
"log"
"os"
"github.com/trackeep/backend/migrations"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
@@ -26,12 +26,20 @@ func getJWTSecret() string {
// InitDatabase initializes the database connection
func InitDatabase() {
// Initialize logger first
InitLogger()
logger := GetLogger()
// Check if demo mode is enabled
if os.Getenv("VITE_DEMO_MODE") == "true" {
logger.Info("Demo mode enabled - skipping database initialization")
return
}
var err error
// Configure GORM logger
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
// Configure GORM
gormConfig := &gorm.Config{}
dbType := os.Getenv("DB_TYPE")
if dbType == "" {
@@ -49,19 +57,28 @@ func InitDatabase() {
os.Getenv("DB_SSL_MODE"),
)
DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
log.Println("Using PostgreSQL database")
logger.Info("Using PostgreSQL database")
default:
log.Fatal("Unsupported database type: " + dbType)
logger.Fatal("Unsupported database type", zap.String("type", dbType))
}
if err != nil {
log.Fatal("Failed to connect to database:", err)
logger.Fatal("Failed to connect to database", zap.Error(err))
}
log.Println("Database connected successfully")
logger.Info("Database connected successfully")
// Run database migrations
if err := migrations.RunMigrations(); err != nil {
logger.Fatal("Failed to run database migrations", zap.Error(err))
}
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
// In demo mode, return nil since no database is available
if os.Getenv("VITE_DEMO_MODE") == "true" {
return nil
}
return DB
}
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Logger *zap.Logger
// InitLogger initializes the Zap logger
func InitLogger() {
// Get log level from environment
logLevel := os.Getenv("LOG_LEVEL")
if logLevel == "" {
logLevel = "info"
}
// Parse log level
var level zapcore.Level
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
default:
level = zapcore.InfoLevel
}
// Check if we're in production mode
isProduction := os.Getenv("GIN_MODE") == "release"
// Configure encoder
var encoder zapcore.Encoder
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
if isProduction {
encoder = zapcore.NewJSONEncoder(encoderConfig)
} else {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
}
// Configure output
writeSyncer := zapcore.AddSync(os.Stdout)
// Create core
core := zapcore.NewCore(encoder, writeSyncer, level)
// Create logger
Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
// Replace global logger
zap.ReplaceGlobals(Logger)
Logger.Info("Logger initialized",
zap.String("level", logLevel),
zap.Bool("production", isProduction),
)
}
// GetLogger returns the configured logger instance
func GetLogger() *zap.Logger {
if Logger == nil {
// Fallback to default logger if not initialized
logger, _ := zap.NewProduction()
return logger
}
return Logger
}
// SyncLogger flushes any buffered log entries
func SyncLogger() {
if Logger != nil {
_ = Logger.Sync()
}
}
+156
View File
@@ -0,0 +1,156 @@
package examples
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/trackeep/backend/internal/db"
"github.com/trackeep/backend/internal/db/sqlc"
)
// UserServiceExample demonstrates how to use sqlc with typed queries
type UserServiceExample struct {
db *db.DB
}
// NewUserServiceExample creates a new user service example
func NewUserServiceExample(database *db.DB) *UserServiceExample {
return &UserServiceExample{
db: database,
}
}
// CreateUserExample shows how to create a user with typed queries
func (s *UserServiceExample) CreateUserExample(ctx context.Context, email, passwordHash, firstName, lastName string) (sqlc.User, error) {
// Use typed query - no SQL strings, no reflection
user, err := s.db.CreateUser(ctx, sqlc.CreateUserParams{
Email: email,
PasswordHash: passwordHash,
FirstName: &firstName,
LastName: &lastName,
IsActive: &[]bool{true}[0],
IsVerified: &[]bool{false}[0],
})
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to create user: %w", err)
}
// Convert CreateUserRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// GetUserExample shows how to get a user by ID
func (s *UserServiceExample) GetUserExample(ctx context.Context, userID pgtype.UUID) (sqlc.User, error) {
// Use typed query
user, err := s.db.GetUserByID(ctx, userID)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to get user: %w", err)
}
// Convert GetUserByIDRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// SearchUsersExample shows how to search users with pagination
func (s *UserServiceExample) SearchUsersExample(ctx context.Context, limit, offset int32) ([]sqlc.User, error) {
// Use typed query with parameters
users, err := s.db.ListUsers(ctx, sqlc.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, fmt.Errorf("failed to list users: %w", err)
}
// Convert ListUsersRow to User
result := make([]sqlc.User, len(users))
for i, user := range users {
result[i] = sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
return result, nil
}
// TransactionExample shows how to use transactions with sqlc
func (s *UserServiceExample) TransactionExample(ctx context.Context, email, passwordHash string) (sqlc.User, error) {
// Start transaction
tx, err := s.db.BeginTx(ctx)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Create user within transaction
user, err := tx.CreateUser(ctx, sqlc.CreateUserParams{
Email: email,
PasswordHash: passwordHash,
IsActive: &[]bool{true}[0],
IsVerified: &[]bool{false}[0],
})
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to create user in transaction: %w", err)
}
// Update last login within transaction
err = tx.UpdateLastLogin(ctx, user.ID)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to update last login: %w", err)
}
// Commit transaction
if err := tx.Commit(); err != nil {
return sqlc.User{}, fmt.Errorf("failed to commit transaction: %w", err)
}
// Convert CreateUserRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
+18 -10
View File
@@ -1,6 +1,6 @@
module github.com/trackeep/backend
go 1.24.0
go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.11.0
@@ -12,8 +12,9 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.48.0
github.com/pressly/goose/v3 v3.27.0
golang.org/x/crypto v0.48.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.17.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
@@ -27,7 +28,7 @@ require (
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
@@ -45,8 +46,9 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.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/josharian/intern v1.0.0 // indirect
@@ -55,21 +57,27 @@ require (
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+54 -19
View File
@@ -17,8 +17,8 @@ github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
@@ -34,6 +34,8 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@@ -78,14 +80,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
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=
@@ -109,15 +115,21 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@@ -134,10 +146,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -147,8 +165,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -156,6 +175,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -166,8 +189,10 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -183,8 +208,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -194,6 +219,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -208,8 +235,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -230,8 +257,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -244,8 +271,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -260,4 +287,12 @@ gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+41 -10
View File
@@ -11,16 +11,16 @@ import (
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type LoginRequest struct {
@@ -60,20 +60,51 @@ type PasswordResetCode struct {
// JWT Claims structure
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
UserID uint `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
GitHubID int `json:"github_id,omitempty"`
AccessToken string `json:"access_token,omitempty"`
jwt.RegisteredClaims
}
// getDurationEnv parses duration from environment variable with fallback
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
seconds, err := strconv.Atoi(value)
if err != nil {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return time.Duration(seconds) * time.Second
}
// GenerateJWT creates a new JWT token for a user
func GenerateJWT(user models.User) (string, error) {
return generateJWT(user, "")
}
func GenerateJWTWithGitHubAccessToken(user models.User, accessToken string) (string, error) {
return generateJWT(user, accessToken)
}
func generateJWT(user models.User, accessToken string) (string, error) {
claims := &Claims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
UserID: user.ID,
Email: user.Email,
Username: user.Username,
GitHubID: user.GitHubID,
AccessToken: accessToken,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "trackeep",
},
+64 -105
View File
@@ -8,13 +8,12 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/oauth2"
"gorm.io/gorm"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
@@ -53,6 +52,8 @@ type GitHubRepo struct {
FullName string `json:"full_name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
Private bool `json:"private"`
Stargazers int `json:"stargazers_count"`
Forks int `json:"forks_count"`
Watchers int `json:"watchers_count"`
@@ -66,6 +67,14 @@ type GitHubRepo struct {
// GitHubLogin initiates the GitHub OAuth flow
func GitHubLogin(c *gin.Context) {
frontendRedirect := resolveFrontendRedirectURL(c.Request)
callbackURL := buildOAuthCallbackURL(c.Request, frontendRedirect)
if oauthServiceURL := getOAuthServiceURL(); oauthServiceURL != "" && callbackURL != "" {
redirectURL := fmt.Sprintf("%s/auth/github?redirect_uri=%s", oauthServiceURL, url.QueryEscape(callbackURL))
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
return
}
if githubOAuthConfig == nil {
initGitHubOAuth()
}
@@ -119,55 +128,35 @@ func GitHubCallback(c *gin.Context) {
}
// Get or create user in database
db := c.MustGet("db").(*gorm.DB)
var existingUser models.User
// First try to find by GitHub ID
err = db.Where("github_id = ?", user.ID).First(&existingUser).Error
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
existingUser, err := upsertCentralizedOAuthUser(db, centralizedOAuthUser{
GitHubID: user.ID,
Username: user.Login,
Email: user.Email,
Name: user.Name,
AvatarURL: user.AvatarURL,
})
if err != nil {
// If not found by GitHub ID, try by email
err = db.Where("email = ?", user.Email).First(&existingUser).Error
if err != nil {
// Create new user
newUser := models.User{
Username: user.Login,
Email: user.Email,
FullName: user.Name,
GitHubID: user.ID,
AvatarURL: user.AvatarURL,
Provider: "github",
}
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
existingUser = newUser
} else {
// Update existing user with GitHub info
existingUser.GitHubID = user.ID
existingUser.AvatarURL = user.AvatarURL
existingUser.Provider = "github"
db.Save(&existingUser)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
return
}
// Generate JWT token
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": existingUser.ID,
"email": existingUser.Email,
"username": existingUser.Username,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
})
tokenString, err := jwtToken.SignedString([]byte(config.JWTSecret))
tokenString, err := GenerateJWTWithGitHubAccessToken(*existingUser, token.AccessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Redirect to frontend with token
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), tokenString)
redirectURL := buildFrontendCallbackRedirectURL("", tokenString)
if redirectURL == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
return
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
@@ -259,69 +248,41 @@ func HandleOAuthCallback(c *gin.Context) {
return
}
// Parse the JWT from the OAuth service
claims := jwt.MapClaims{}
parsedToken, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
// Use the OAuth service's JWT secret (should be shared)
return []byte(os.Getenv("OAUTH_JWT_SECRET")), nil
})
if err != nil || !parsedToken.Valid {
validationResponse, err := validateCentralizedOAuthToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
return
}
// Extract user information from OAuth service
username, _ := claims["username"].(string)
email, _ := claims["email"].(string)
githubID, _ := claims["github_id"]
accessToken, _ := claims["access_token"].(string)
// Get database
db := c.MustGet("db").(*gorm.DB)
// Find or create user in local database
var user models.User
err = db.Where("email = ?", email).First(&user).Error
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
localUser, err := upsertCentralizedOAuthUser(db, validationResponse.User)
if err != nil {
// Create new user
newUser := models.User{
Username: username,
Email: email,
GitHubID: int(githubID.(float64)), // JWT numbers are float64
Provider: "github",
}
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
user = newUser
} else {
// Update existing user with GitHub info
user.GitHubID = int(githubID.(float64))
user.Provider = "github"
db.Save(&user)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
return
}
// Generate Trackeep JWT token
trackeepToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"username": user.Username,
"github_id": user.GitHubID,
"access_token": accessToken, // Pass through the GitHub access token
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
})
claims, err := parseOAuthTokenClaimsUnverified(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token claims"})
return
}
trackeepTokenString, err := trackeepToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
trackeepTokenString, err := GenerateJWTWithGitHubAccessToken(*localUser, getAccessTokenFromOAuthClaims(claims))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Redirect to frontend with Trackeep token
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), trackeepTokenString)
redirectURL := buildFrontendCallbackRedirectURL(c.Query("frontend_redirect"), trackeepTokenString)
if redirectURL == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
return
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
@@ -343,7 +304,11 @@ func GetCurrentUserWithGitHub(c *gin.Context) {
func GetGitHubRepos(c *gin.Context) {
userID := c.GetUint("user_id")
db := c.MustGet("db").(*gorm.DB)
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -368,26 +333,20 @@ func GetGitHubRepos(c *gin.Context) {
tokenString = authHeader[7:]
}
// Parse the JWT to get the GitHub access token from the centralized OAuth service
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
})
if err != nil || !token.Valid {
claims, err := ValidateJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
// Extract GitHub access token from the OAuth service JWT
githubAccessToken, ok := claims["access_token"]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found"})
githubAccessToken := strings.TrimSpace(claims.AccessToken)
if githubAccessToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found. Please reconnect GitHub."})
return
}
// Fetch repositories using the GitHub access token
repos, err := fetchGitHubRepos(githubAccessToken.(string))
repos, err := fetchGitHubRepos(githubAccessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
return
+944
View File
@@ -0,0 +1,944 @@
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
"gorm.io/gorm"
)
var githubRepoFullNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
type gitHubInstallationReposResponse struct {
Repositories []GitHubRepo `json:"repositories"`
}
type gitHubAppInstallationDetails struct {
ID int64 `json:"id"`
Account struct {
Login string `json:"login"`
Type string `json:"type"`
} `json:"account"`
}
type gitHubInstallationTokenResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
type gitHubBackupRequest struct {
Repositories []string `json:"repositories"`
Source string `json:"source"`
}
type gitHubBackupResult struct {
Repository string `json:"repository"`
Status string `json:"status"`
LocalPath string `json:"local_path"`
Source string `json:"source"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// GetGitHubAppStatus returns install/configuration status for GitHub App integration.
func GetGitHubAppStatus(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
response := gin.H{
"app_slug": getGitHubAppSlug(),
"install_enabled": isGitHubAppInstallEnabled(),
"credentials_configured": hasGitHubAppCredentials(),
"installed": false,
}
if db == nil {
c.JSON(http.StatusOK, response)
return
}
installation, err := getUserGitHubInstallation(db, userID)
if err == nil {
response["installed"] = true
response["installation"] = installation
}
c.JSON(http.StatusOK, response)
}
// GetGitHubAppInstallURL creates a one-time state and returns an install URL for the configured GitHub App.
func GetGitHubAppInstallURL(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
if !isGitHubAppInstallEnabled() {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App slug is not configured"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
state := generateRandomString(24)
expiresAt := time.Now().Add(15 * time.Minute)
stateRecord := models.GitHubAppInstallState{
UserID: userID,
State: state,
ExpiresAt: expiresAt,
}
if err := db.Create(&stateRecord).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create install state"})
return
}
installURL := fmt.Sprintf(
"https://github.com/apps/%s/installations/new?state=%s",
url.PathEscape(getGitHubAppSlug()),
url.QueryEscape(state),
)
c.JSON(http.StatusOK, gin.H{
"install_url": installURL,
"expires_at": expiresAt,
})
}
// GitHubAppInstallCallback handles GitHub App setup callback and links installation to a Trackeep user.
func GitHubAppInstallCallback(c *gin.Context) {
state := strings.TrimSpace(c.Query("state"))
installationRaw := strings.TrimSpace(c.Query("installation_id"))
setupAction := strings.TrimSpace(c.Query("setup_action"))
if state == "" || installationRaw == "" {
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "missing_state_or_installation")
return
}
installationID, err := strconv.ParseInt(installationRaw, 10, 64)
if err != nil || installationID <= 0 {
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "invalid_installation_id")
return
}
db := config.GetDB()
if db == nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "database_unavailable")
return
}
var stateRecord models.GitHubAppInstallState
if err := db.Where("state = ?", state).First(&stateRecord).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "invalid_state")
return
}
if stateRecord.UsedAt != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_already_used")
return
}
if time.Now().After(stateRecord.ExpiresAt) {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_expired")
return
}
accountLogin := ""
accountType := ""
lastValidated := (*time.Time)(nil)
if hasGitHubAppCredentials() {
details, detailsErr := fetchGitHubAppInstallationDetails(c.Request.Context(), installationID)
if detailsErr == nil && details != nil {
accountLogin = details.Account.Login
accountType = details.Account.Type
now := time.Now()
lastValidated = &now
}
}
var installation models.GitHubAppInstallation
lookupErr := db.Where("installation_id = ?", installationID).First(&installation).Error
switch {
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
installation = models.GitHubAppInstallation{
UserID: stateRecord.UserID,
InstallationID: installationID,
AppSlug: getGitHubAppSlug(),
AccountLogin: accountLogin,
AccountType: accountType,
LastValidated: lastValidated,
}
if err := db.Create(&installation).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_store_installation")
return
}
case lookupErr != nil:
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_lookup_failed")
return
default:
updates := map[string]interface{}{
"user_id": stateRecord.UserID,
"app_slug": getGitHubAppSlug(),
"account_login": accountLogin,
"account_type": accountType,
}
if lastValidated != nil {
updates["last_validated"] = lastValidated
}
if err := db.Model(&installation).Updates(updates).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_update_installation")
return
}
}
usedAt := time.Now()
if err := db.Model(&stateRecord).Update("used_at", usedAt).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_finalize_state")
return
}
redirectToGitHubIntegrationPage(c, true, installationID, setupAction, "")
}
// GetGitHubAppRepos returns repositories available through the user's GitHub App installation.
func GetGitHubAppRepos(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
installation, err := getUserGitHubInstallation(db, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App is not installed for this user"})
return
}
accessToken, _, err := createGitHubInstallationAccessToken(c.Request.Context(), installation.InstallationID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to create GitHub App installation token: " + err.Error()})
return
}
repos, err := fetchGitHubInstallationRepos(accessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch installation repos: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"source": "github_app",
"installation_id": installation.InstallationID,
"repos": repos,
})
}
// GetGitHubBackups lists local GitHub repository backups for the authenticated user.
func GetGitHubBackups(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var backups []models.GitHubRepoBackup
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").Find(&backups).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository backups"})
return
}
c.JSON(http.StatusOK, gin.H{
"backup_root": getGitHubBackupRoot(),
"backups": backups,
})
}
// BackupGitHubRepositories clones or updates selected repositories in local mirror storage.
func BackupGitHubRepositories(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var req gitHubBackupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if len(req.Repositories) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "At least one repository must be provided"})
return
}
accessToken, source, installationID, err := resolveGitHubBackupToken(c, db, userID, req.Source)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := os.MkdirAll(getGitHubBackupRoot(), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup directory"})
return
}
knownRepos := make(map[string]GitHubRepo)
switch source {
case "oauth":
repos, reposErr := fetchGitHubRepos(accessToken)
if reposErr == nil {
for _, repo := range repos {
knownRepos[strings.ToLower(repo.FullName)] = repo
}
}
case "github_app":
repos, reposErr := fetchGitHubInstallationRepos(accessToken)
if reposErr == nil {
for _, repo := range repos {
knownRepos[strings.ToLower(repo.FullName)] = repo
}
}
}
results := make([]gitHubBackupResult, 0, len(req.Repositories))
successCount := 0
failedCount := 0
seen := make(map[string]struct{})
for _, rawRepo := range req.Repositories {
repoFullName, normalizeErr := normalizeGitHubRepoFullName(rawRepo)
if normalizeErr != nil {
failedCount++
results = append(results, gitHubBackupResult{
Repository: strings.TrimSpace(rawRepo),
Status: "error",
Source: source,
Error: normalizeErr.Error(),
})
continue
}
if _, exists := seen[repoFullName]; exists {
continue
}
seen[repoFullName] = struct{}{}
repoInfo, hasInfo := knownRepos[strings.ToLower(repoFullName)]
if !hasInfo {
repoDetails, fetchErr := fetchGitHubRepoByFullName(accessToken, repoFullName)
if fetchErr == nil && repoDetails != nil {
repoInfo = *repoDetails
}
}
if repoInfo.FullName == "" {
repoInfo.FullName = repoFullName
parts := strings.SplitN(repoFullName, "/", 2)
if len(parts) == 2 {
repoInfo.Name = parts[1]
}
repoInfo.CloneURL = fmt.Sprintf("https://github.com/%s.git", repoFullName)
}
localPath := buildGitHubBackupPath(userID, repoFullName)
repoCtx, cancel := context.WithTimeout(c.Request.Context(), getGitHubBackupTimeout())
sizeBytes, backupErr := backupGitHubRepositoryMirror(repoCtx, accessToken, repoFullName, localPath)
cancel()
result := gitHubBackupResult{
Repository: repoFullName,
LocalPath: localPath,
Source: source,
}
now := time.Now()
record := models.GitHubRepoBackup{
UserID: userID,
RepositoryID: int64(repoInfo.ID),
RepositoryName: repoInfo.Name,
RepositoryFullName: repoFullName,
DefaultBranch: repoInfo.DefaultBranch,
CloneURL: repoInfo.CloneURL,
LocalPath: localPath,
Source: source,
InstallationID: installationID,
LastBackupAt: &now,
}
if backupErr != nil {
failedCount++
result.Status = "error"
result.Error = backupErr.Error()
record.LastBackupStatus = "error"
record.LastBackupError = backupErr.Error()
record.LastBackupSize = 0
} else {
successCount++
result.Status = "success"
result.SizeBytes = sizeBytes
record.LastBackupStatus = "success"
record.LastBackupError = ""
record.LastBackupSize = sizeBytes
}
if upsertErr := upsertGitHubBackupRecord(db, record); upsertErr != nil {
if result.Status == "success" {
result.Status = "error"
result.Error = "backup persisted but metadata update failed: " + upsertErr.Error()
successCount--
failedCount++
}
}
results = append(results, result)
}
c.JSON(http.StatusOK, gin.H{
"source": source,
"installation_id": installationID,
"backed_up": successCount,
"failed": failedCount,
"results": results,
})
}
func getGitHubRequestUserID(c *gin.Context) uint {
userID := c.GetUint("user_id")
if userID == 0 {
userID = c.GetUint("userID")
}
return userID
}
func getUserGitHubInstallation(db *gorm.DB, userID uint) (*models.GitHubAppInstallation, error) {
var installation models.GitHubAppInstallation
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").First(&installation).Error; err != nil {
return nil, err
}
return &installation, nil
}
func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
source := strings.ToLower(strings.TrimSpace(requestedSource))
switch source {
case "", "oauth":
accessToken, err := getGitHubOAuthAccessTokenFromHeader(c)
if err == nil {
return accessToken, "oauth", nil, nil
}
accessToken, installationID, appErr := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
if appErr == nil {
return accessToken, "github_app", &installationID, nil
}
return "", "", nil, fmt.Errorf("no usable GitHub OAuth token and GitHub App fallback failed")
case "github_app", "app":
accessToken, installationID, err := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
if err != nil {
return "", "", nil, err
}
return accessToken, "github_app", &installationID, nil
default:
return "", "", nil, fmt.Errorf("unsupported source '%s'", requestedSource)
}
}
func getGitHubOAuthAccessTokenFromHeader(c *gin.Context) (string, error) {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if authHeader == "" {
return "", errors.New("authorization header required")
}
tokenString := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
tokenString = strings.TrimSpace(authHeader[7:])
}
if tokenString == "" {
return "", errors.New("invalid authorization header")
}
claims, err := ValidateJWT(tokenString)
if err != nil {
return "", err
}
accessToken := strings.TrimSpace(claims.AccessToken)
if accessToken == "" {
return "", errors.New("github oauth token missing in jwt")
}
return accessToken, nil
}
func getGitHubAppAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, int64, error) {
installation, err := getUserGitHubInstallation(db, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", 0, errors.New("GitHub App not installed for this user")
}
return "", 0, err
}
accessToken, _, err := createGitHubInstallationAccessToken(ctx, installation.InstallationID)
if err != nil {
return "", 0, err
}
return accessToken, installation.InstallationID, nil
}
func upsertGitHubBackupRecord(db *gorm.DB, record models.GitHubRepoBackup) error {
var existing models.GitHubRepoBackup
err := db.Where("user_id = ? AND repository_full_name = ?", record.UserID, record.RepositoryFullName).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return db.Create(&record).Error
case err != nil:
return err
default:
updates := map[string]interface{}{
"repository_id": record.RepositoryID,
"repository_name": record.RepositoryName,
"default_branch": record.DefaultBranch,
"clone_url": record.CloneURL,
"local_path": record.LocalPath,
"source": record.Source,
"installation_id": record.InstallationID,
"last_backup_at": record.LastBackupAt,
"last_backup_status": record.LastBackupStatus,
"last_backup_error": record.LastBackupError,
"last_backup_size": record.LastBackupSize,
}
return db.Model(&existing).Updates(updates).Error
}
}
func normalizeGitHubRepoFullName(raw string) (string, error) {
normalized := strings.TrimSpace(raw)
normalized = strings.TrimSuffix(normalized, ".git")
normalized = strings.TrimPrefix(normalized, "https://github.com/")
normalized = strings.TrimPrefix(normalized, "http://github.com/")
normalized = strings.TrimPrefix(normalized, "github.com/")
normalized = strings.Trim(normalized, "/")
if !githubRepoFullNamePattern.MatchString(normalized) {
return "", fmt.Errorf("invalid repository '%s', expected owner/repo", raw)
}
return normalized, nil
}
func buildGitHubBackupPath(userID uint, repoFullName string) string {
parts := strings.SplitN(repoFullName, "/", 2)
owner := "unknown"
repo := repoFullName
if len(parts) == 2 {
owner = parts[0]
repo = parts[1]
}
return filepath.Join(getGitHubBackupRoot(), fmt.Sprintf("user-%d", userID), owner, repo+".git")
}
func getGitHubBackupRoot() string {
root := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_ROOT"))
if root == "" {
root = filepath.Join("data", "github-backups")
}
absolutePath, err := filepath.Abs(root)
if err != nil {
return root
}
return absolutePath
}
func getGitHubBackupTimeout() time.Duration {
timeoutRaw := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_TIMEOUT"))
if timeoutRaw == "" {
return 10 * time.Minute
}
parsed, err := time.ParseDuration(timeoutRaw)
if err != nil || parsed <= 0 {
return 10 * time.Minute
}
return parsed
}
func backupGitHubRepositoryMirror(ctx context.Context, accessToken, repoFullName, localPath string) (int64, error) {
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return 0, fmt.Errorf("failed to create backup parent directory: %w", err)
}
repoURL := fmt.Sprintf("https://github.com/%s.git", repoFullName)
gitAuthHeader := "http.extraHeader=Authorization: Bearer " + accessToken
cloneRequired := true
if info, err := os.Stat(localPath); err == nil {
if !info.IsDir() {
return 0, fmt.Errorf("backup path exists and is not a directory: %s", localPath)
}
if _, configErr := os.Stat(filepath.Join(localPath, "config")); configErr == nil {
cloneRequired = false
} else if errors.Is(configErr, os.ErrNotExist) {
if removeErr := os.RemoveAll(localPath); removeErr != nil {
return 0, fmt.Errorf("failed to reset invalid backup directory: %w", removeErr)
}
} else {
return 0, fmt.Errorf("failed to inspect existing backup directory: %w", configErr)
}
} else if !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("failed to access backup path: %w", err)
}
var cmd *exec.Cmd
if cloneRequired {
cmd = exec.CommandContext(ctx, "git", "-c", gitAuthHeader, "clone", "--mirror", repoURL, localPath)
} else {
cmd = exec.CommandContext(ctx, "git", "-C", localPath, "-c", gitAuthHeader, "remote", "update", "--prune")
}
output, err := cmd.CombinedOutput()
if err != nil {
commandOutput := strings.TrimSpace(string(output))
if commandOutput == "" {
commandOutput = err.Error()
}
return 0, fmt.Errorf("git backup failed: %s", commandOutput)
}
sizeBytes, err := calculateDirectorySize(localPath)
if err != nil {
return 0, fmt.Errorf("backup completed but failed to calculate size: %w", err)
}
return sizeBytes, nil
}
func calculateDirectorySize(root string) (int64, error) {
var totalSize int64
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
totalSize += info.Size()
return nil
})
if err != nil {
return 0, err
}
return totalSize, nil
}
func fetchGitHubInstallationRepos(accessToken string) ([]GitHubRepo, error) {
req, err := http.NewRequest("GET", "https://api.github.com/installation/repositories?per_page=100", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var response gitHubInstallationReposResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
return response.Repositories, nil
}
func fetchGitHubRepoByFullName(accessToken, repoFullName string) (*GitHubRepo, error) {
parts := strings.SplitN(repoFullName, "/", 2)
if len(parts) != 2 {
return nil, errors.New("invalid repository full name")
}
repoURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s",
url.PathEscape(parts[0]),
url.PathEscape(parts[1]),
)
req, err := http.NewRequest("GET", repoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var repo GitHubRepo
if err := json.Unmarshal(body, &repo); err != nil {
return nil, err
}
return &repo, nil
}
func isGitHubAppInstallEnabled() bool {
return getGitHubAppSlug() != ""
}
func hasGitHubAppCredentials() bool {
return strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) != ""
}
func getGitHubAppSlug() string {
return strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
}
func createGitHubInstallationAccessToken(ctx context.Context, installationID int64) (string, time.Time, error) {
if !hasGitHubAppCredentials() {
return "", time.Time{}, errors.New("GitHub App credentials are not fully configured")
}
appJWT, err := createGitHubAppJWT()
if err != nil {
return "", time.Time{}, err
}
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID)
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, nil)
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Authorization", "Bearer "+appJWT)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", time.Time{}, fmt.Errorf("GitHub token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var payload gitHubInstallationTokenResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", time.Time{}, err
}
if strings.TrimSpace(payload.Token) == "" {
return "", time.Time{}, errors.New("GitHub returned an empty installation token")
}
var expiresAt time.Time
if payload.ExpiresAt != "" {
parsed, parseErr := time.Parse(time.RFC3339, payload.ExpiresAt)
if parseErr == nil {
expiresAt = parsed
}
}
return payload.Token, expiresAt, nil
}
func fetchGitHubAppInstallationDetails(ctx context.Context, installationID int64) (*gitHubAppInstallationDetails, error) {
if !hasGitHubAppCredentials() {
return nil, errors.New("GitHub App credentials are not configured")
}
appJWT, err := createGitHubAppJWT()
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d", installationID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+appJWT)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub installation endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var details gitHubAppInstallationDetails
if err := json.Unmarshal(body, &details); err != nil {
return nil, err
}
return &details, nil
}
func createGitHubAppJWT() (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
if appID == "" {
return "", errors.New("GITHUB_APP_ID is not configured")
}
privateKeyPEM, err := loadGitHubAppPrivateKey()
if err != nil {
return "", err
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("failed to parse GitHub App private key: %w", err)
}
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: appID,
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign GitHub App JWT: %w", err)
}
return signedToken, nil
}
func loadGitHubAppPrivateKey() ([]byte, error) {
raw := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
if raw == "" {
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is not configured")
}
normalized := strings.ReplaceAll(raw, "\\n", "\n")
if strings.Contains(normalized, "BEGIN ") {
return []byte(normalized), nil
}
decoded, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is neither PEM nor base64-encoded PEM")
}
return decoded, nil
}
func redirectToGitHubIntegrationPage(c *gin.Context, success bool, installationID int64, setupAction, errorCode string) {
frontendURL := strings.TrimSpace(os.Getenv("FRONTEND_URL"))
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
frontendURL = strings.TrimRight(frontendURL, "/")
params := url.Values{}
if success {
params.Set("github_app_installed", "1")
params.Set("installation_id", strconv.FormatInt(installationID, 10))
if setupAction != "" {
params.Set("setup_action", setupAction)
}
} else {
params.Set("github_app_error", errorCode)
if installationID > 0 {
params.Set("installation_id", strconv.FormatInt(installationID, 10))
}
}
redirectURL := fmt.Sprintf("%s/app/github?%s", frontendURL, params.Encode())
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func truncateString(value string, limit int) string {
if len(value) <= limit {
return value
}
if limit < 4 {
return value[:limit]
}
return value[:limit-3] + "..."
}
+365
View File
@@ -0,0 +1,365 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/trackeep/backend/models"
)
const defaultOAuthServiceURL = "https://oauth.trackeep.org"
type centralizedOAuthUser struct {
ID int `json:"id"`
GitHubID int `json:"github_id"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
type centralizedOAuthValidationResponse struct {
Token string `json:"token"`
User centralizedOAuthUser `json:"user"`
}
func getOAuthServiceURL() string {
value := strings.TrimSpace(os.Getenv("OAUTH_SERVICE_URL"))
if value == "" {
value = strings.TrimSpace(os.Getenv("VITE_OAUTH_SERVICE_URL"))
}
if value == "" {
value = defaultOAuthServiceURL
}
return strings.TrimRight(value, "/")
}
func headerValue(headers http.Header, key string) string {
raw := strings.TrimSpace(headers.Get(key))
if raw == "" {
return ""
}
for _, part := range strings.Split(raw, ",") {
candidate := strings.TrimSpace(part)
if candidate != "" {
return candidate
}
}
return ""
}
func backendPublicBaseURL(r *http.Request) string {
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_API_URL")); baseURL != "" {
return strings.TrimRight(baseURL, "/")
}
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); baseURL != "" {
return strings.TrimRight(baseURL, "/")
}
scheme := "http"
if forwardedProto := headerValue(r.Header, "X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else if r.TLS != nil {
scheme = "https"
}
host := headerValue(r.Header, "X-Forwarded-Host")
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
return fmt.Sprintf("%s://%s", scheme, host)
}
func normalizeFrontendRedirectURL(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
if parsed.Path == "" || parsed.Path == "/" {
parsed.Path = "/auth/callback"
}
return parsed.String()
}
func resolveFrontendRedirectURL(r *http.Request) string {
if value := normalizeFrontendRedirectURL(r.URL.Query().Get("frontend_redirect")); value != "" {
return value
}
if value := normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL")); value != "" {
return value
}
if origin := normalizeFrontendRedirectURL(r.Header.Get("Origin")); origin != "" {
return origin
}
referer := strings.TrimSpace(r.Header.Get("Referer"))
if referer != "" {
if parsed, err := url.Parse(referer); err == nil && parsed.Scheme != "" && parsed.Host != "" {
return normalizeFrontendRedirectURL((&url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: "/auth/callback",
}).String())
}
}
return ""
}
func buildOAuthCallbackURL(r *http.Request, frontendRedirect string) string {
baseURL := backendPublicBaseURL(r)
if baseURL == "" {
return ""
}
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/oauth/callback")
if err != nil {
return ""
}
if frontendRedirect != "" {
query := callbackURL.Query()
query.Set("frontend_redirect", frontendRedirect)
callbackURL.RawQuery = query.Encode()
}
return callbackURL.String()
}
func buildFrontendCallbackRedirectURL(frontendRedirect, token string) string {
redirectTarget := normalizeFrontendRedirectURL(frontendRedirect)
if redirectTarget == "" {
redirectTarget = normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL"))
}
if redirectTarget == "" {
return ""
}
parsed, err := url.Parse(redirectTarget)
if err != nil {
return ""
}
query := parsed.Query()
query.Set("token", token)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func validateCentralizedOAuthToken(ctx context.Context, token string) (*centralizedOAuthValidationResponse, error) {
serviceURL := getOAuthServiceURL()
if serviceURL == "" {
return nil, fmt.Errorf("oauth service url not configured")
}
requestBody, err := json.Marshal(map[string]string{"token": token})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/api/v1/auth/oauth/callback", bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("oauth service validation failed: %s", message)
}
var response centralizedOAuthValidationResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
return &response, nil
}
func parseOAuthTokenClaimsUnverified(token string) (jwt.MapClaims, error) {
parser := jwt.NewParser()
parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, err
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
func getAccessTokenFromOAuthClaims(claims jwt.MapClaims) string {
accessToken, _ := claims["access_token"].(string)
return strings.TrimSpace(accessToken)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func uniqueUsername(base string, db *gorm.DB, excludeUserID uint) string {
candidate := strings.TrimSpace(base)
if candidate == "" {
candidate = "user"
}
for suffix := 0; ; suffix++ {
username := candidate
if suffix > 0 {
username = fmt.Sprintf("%s-%d", candidate, suffix+1)
}
var existing models.User
err := db.Where("username = ?", username).First(&existing).Error
if err == nil {
if excludeUserID != 0 && existing.ID == excludeUserID {
return username
}
continue
}
if err == gorm.ErrRecordNotFound {
return username
}
return username
}
}
func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser) (*models.User, error) {
var user models.User
var err error
normalizedEmail := strings.TrimSpace(controllerUser.Email)
normalizedUsername := firstNonEmpty(controllerUser.Username, strings.Split(normalizedEmail, "@")[0], "user")
fullName := firstNonEmpty(controllerUser.Name, controllerUser.Username, normalizedEmail)
provider := "email"
if controllerUser.GitHubID != 0 {
provider = "github"
err = db.Where("github_id = ?", controllerUser.GitHubID).First(&user).Error
} else {
err = gorm.ErrRecordNotFound
}
if err != nil && normalizedEmail != "" {
err = db.Where("email = ?", normalizedEmail).First(&user).Error
}
if err == nil {
updates := map[string]interface{}{
"email": normalizedEmail,
"username": uniqueUsername(normalizedUsername, db, user.ID),
"full_name": fullName,
"avatar_url": controllerUser.AvatarURL,
"provider": provider,
}
if controllerUser.GitHubID != 0 {
updates["github_id"] = controllerUser.GitHubID
}
now := time.Now()
updates["last_login_at"] = &now
if err := db.Model(&user).Updates(updates).Error; err != nil {
return nil, err
}
if err := db.First(&user, user.ID).Error; err != nil {
return nil, err
}
return &user, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
var userCount int64
if err := db.Model(&models.User{}).Count(&userCount).Error; err != nil {
return nil, err
}
randomPassword := generateRandomString(32)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
role := "user"
if userCount == 0 {
role = "admin"
}
now := time.Now()
user = models.User{
Email: normalizedEmail,
Username: uniqueUsername(normalizedUsername, db, 0),
Password: string(hashedPassword),
FullName: fullName,
Role: role,
Theme: "dark",
GitHubID: controllerUser.GitHubID,
AvatarURL: controllerUser.AvatarURL,
Provider: provider,
LastLoginAt: &now,
}
if err := db.Create(&user).Error; err != nil {
return nil, err
}
_ = ensureMessagingDefaults(db, user.ID)
return &user, nil
}
+95
View File
@@ -0,0 +1,95 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestValidateCentralizedOAuthToken(t *testing.T) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST request, got %s", r.Method)
}
if r.URL.Path != "/api/v1/auth/oauth/callback" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if body["token"] != "controller-token" {
t.Fatalf("unexpected token payload: %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(centralizedOAuthValidationResponse{
Token: "controller-token",
User: centralizedOAuthUser{
ID: 42,
GitHubID: 99,
Username: "octocat",
Email: "octocat@example.com",
},
})
}))
defer server.Close()
t.Setenv("OAUTH_SERVICE_URL", server.URL)
t.Setenv("VITE_OAUTH_SERVICE_URL", "")
response, err := validateCentralizedOAuthToken(context.Background(), "controller-token")
if err != nil {
t.Fatalf("validateCentralizedOAuthToken returned error: %v", err)
}
if response.User.Username != "octocat" {
t.Fatalf("unexpected user returned: %#v", response.User)
}
}
func TestBuildOAuthCallbackURLPreservesFrontendRedirect(t *testing.T) {
frontendRedirect := "https://app.example.com/auth/callback?next=%2Fapp"
req := httptest.NewRequest(http.MethodGet, "http://internal/api/v1/auth/github?frontend_redirect="+url.QueryEscape(frontendRedirect), nil)
req.Host = "api.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.example.com")
resolvedFrontendRedirect := resolveFrontendRedirectURL(req)
if resolvedFrontendRedirect != frontendRedirect {
t.Fatalf("unexpected frontend redirect: %s", resolvedFrontendRedirect)
}
callbackURL := buildOAuthCallbackURL(req, resolvedFrontendRedirect)
expected := "https://api.example.com/api/v1/auth/oauth/callback?frontend_redirect=" + url.QueryEscape(frontendRedirect)
if callbackURL != expected {
t.Fatalf("unexpected callback URL: got %s want %s", callbackURL, expected)
}
}
func TestParseOAuthTokenClaimsUnverified(t *testing.T) {
signedToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": 1,
"access_token": "gho_test_token",
}).SignedString([]byte("test-secret"))
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
claims, err := parseOAuthTokenClaimsUnverified(signedToken)
if err != nil {
t.Fatalf("parseOAuthTokenClaimsUnverified returned error: %v", err)
}
if accessToken := getAccessTokenFromOAuthClaims(claims); accessToken != "gho_test_token" {
t.Fatalf("unexpected access token: %s", accessToken)
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func GetUpdateSettingsForAPI(userID int) (UpdateSettings, error) {
func getDefaultUpdateSettings() UpdateSettings {
return UpdateSettings{
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.tdvorak.dev"),
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.trackeep.org"),
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
+87
View File
@@ -0,0 +1,87 @@
package db
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/trackeep/backend/internal/db/sqlc"
)
// DB wraps the sqlc DB with additional functionality
type DB struct {
*sqlc.Queries
pool *pgxpool.Pool
}
// NewDB creates a new database connection
func NewDB() (*DB, error) {
// Get database connection string
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
os.Getenv("DB_HOST"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
os.Getenv("DB_PORT"),
os.Getenv("DB_SSL_MODE"),
)
// Create connection pool
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Create queries instance
queries := sqlc.New(pool)
return &DB{
Queries: queries,
pool: pool,
}, nil
}
// Close closes the database connection
func (db *DB) Close() error {
db.pool.Close()
return nil
}
// BeginTx starts a transaction
func (db *DB) BeginTx(ctx context.Context) (*DB, error) {
tx, err := db.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
return &DB{
Queries: sqlc.New(tx),
pool: nil, // Not using pool in transaction mode
}, nil
}
// Commit commits the transaction
func (db *DB) Commit() error {
// This would need to be implemented with transaction tracking
// For now, transactions should be handled by the caller
return nil
}
// Rollback rolls back the transaction
func (db *DB) Rollback() error {
// This would need to be implemented with transaction tracking
// For now, transactions should be handled by the caller
return nil
}
// GetPool returns the underlying connection pool
func (db *DB) GetPool() *pgxpool.Pool {
return db.pool
}
+340
View File
@@ -0,0 +1,340 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: bookmarks.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddBookmarkTag = `-- name: AddBookmarkTag :exec
INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`
type AddBookmarkTagParams struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error {
_, err := q.db.Exec(ctx, AddBookmarkTag, arg.BookmarkID, arg.TagID)
return err
}
const CreateBookmark = `-- name: CreateBookmark :one
INSERT INTO bookmarks (title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
`
type CreateBookmarkParams struct {
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
UserID pgtype.UUID `json:"userId"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
}
func (q *Queries) CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, CreateBookmark,
arg.Title,
arg.Url,
arg.Description,
arg.FaviconUrl,
arg.ScreenshotUrl,
arg.UserID,
arg.IsArchived,
arg.IsFavorite,
)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteBookmark = `-- name: DeleteBookmark :exec
DELETE FROM bookmarks WHERE id = $1 AND user_id = $2
`
type DeleteBookmarkParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error {
_, err := q.db.Exec(ctx, DeleteBookmark, arg.ID, arg.UserID)
return err
}
const GetBookmarkByID = `-- name: GetBookmarkByID :one
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE id = $1 AND user_id = $2 LIMIT 1
`
type GetBookmarkByIDParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, GetBookmarkByID, arg.ID, arg.UserID)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetBookmarksByTag = `-- name: GetBookmarksByTag :many
SELECT b.id, b.title, b.url, b.description, b.favicon_url, b.screenshot_url, b.user_id, b.is_archived, b.is_favorite, b.created_at, b.updated_at
FROM bookmarks b
INNER JOIN bookmark_tags bt ON b.id = bt.bookmark_id
INNER JOIN tags t ON bt.tag_id = t.id
WHERE t.id = $1 AND b.user_id = $2
ORDER BY b.created_at DESC
LIMIT $3 OFFSET $4
`
type GetBookmarksByTagParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, GetBookmarksByTag,
arg.ID,
arg.UserID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetBookmarksByUser = `-- name: GetBookmarksByUser :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type GetBookmarksByUserParams struct {
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, GetBookmarksByUser, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveBookmarkTag = `-- name: RemoveBookmarkTag :exec
DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2
`
type RemoveBookmarkTagParams struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error {
_, err := q.db.Exec(ctx, RemoveBookmarkTag, arg.BookmarkID, arg.TagID)
return err
}
const SearchBookmarks = `-- name: SearchBookmarks :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2 OR
url ILIKE $2
)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`
type SearchBookmarksParams struct {
UserID pgtype.UUID `json:"userId"`
Title string `json:"title"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, SearchBookmarks,
arg.UserID,
arg.Title,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateBookmark = `-- name: UpdateBookmark :one
UPDATE bookmarks
SET title = $2,
url = $3,
description = $4,
favicon_url = $5,
screenshot_url = $6,
is_archived = $7,
is_favorite = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $9
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
`
type UpdateBookmarkParams struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, UpdateBookmark,
arg.ID,
arg.Title,
arg.Url,
arg.Description,
arg.FaviconUrl,
arg.ScreenshotUrl,
arg.IsArchived,
arg.IsFavorite,
arg.UserID,
)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+115
View File
@@ -0,0 +1,115 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
)
type AuditLog struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID pgtype.UUID `json:"resourceId"`
OldValues []byte `json:"oldValues"`
NewValues []byte `json:"newValues"`
IpAddress *netip.Addr `json:"ipAddress"`
UserAgent *string `json:"userAgent"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
}
type Bookmark struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
UserID pgtype.UUID `json:"userId"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type BookmarkTag struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
type File struct {
ID pgtype.UUID `json:"id"`
Filename string `json:"filename"`
OriginalName string `json:"originalName"`
FileSize int64 `json:"fileSize"`
MimeType *string `json:"mimeType"`
FilePath string `json:"filePath"`
ThumbnailPath *string `json:"thumbnailPath"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type FileTag struct {
FileID pgtype.UUID `json:"fileId"`
TagID pgtype.UUID `json:"tagId"`
}
type Note struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Content *string `json:"content"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type NoteTag struct {
NoteID pgtype.UUID `json:"noteId"`
TagID pgtype.UUID `json:"tagId"`
}
type Tag struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Color *string `json:"color"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type Task struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type TaskTag struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
+42
View File
@@ -0,0 +1,42 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error
AddTaskTag(ctx context.Context, arg AddTaskTagParams) error
CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error
DeleteTask(ctx context.Context, arg DeleteTaskParams) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error)
GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error)
GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error)
GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error)
GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error)
GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error)
GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error)
ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error)
RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error
RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error
SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error)
SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error)
UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error)
UpdateLastLogin(ctx context.Context, id pgtype.UUID) error
UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error)
UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error)
}
var _ Querier = (*Queries)(nil)
+395
View File
@@ -0,0 +1,395 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tasks.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddTaskTag = `-- name: AddTaskTag :exec
INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`
type AddTaskTagParams struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) AddTaskTag(ctx context.Context, arg AddTaskTagParams) error {
_, err := q.db.Exec(ctx, AddTaskTag, arg.TaskID, arg.TagID)
return err
}
const CreateTask = `-- name: CreateTask :one
INSERT INTO tasks (title, description, status, priority, due_date, user_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
`
type CreateTaskParams struct {
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, CreateTask,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.UserID,
)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteTask = `-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND user_id = $2
`
type DeleteTaskParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) DeleteTask(ctx context.Context, arg DeleteTaskParams) error {
_, err := q.db.Exec(ctx, DeleteTask, arg.ID, arg.UserID)
return err
}
const GetTaskByID = `-- name: GetTaskByID :one
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE id = $1 AND user_id = $2 LIMIT 1
`
type GetTaskByIDParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) {
row := q.db.QueryRow(ctx, GetTaskByID, arg.ID, arg.UserID)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetTasksByStatus = `-- name: GetTasksByStatus :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND status = $2
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4
`
type GetTasksByStatusParams struct {
UserID pgtype.UUID `json:"userId"`
Status *string `json:"status"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByStatus,
arg.UserID,
arg.Status,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetTasksByTag = `-- name: GetTasksByTag :many
SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, t.user_id, t.created_at, t.updated_at
FROM tasks t
INNER JOIN task_tags tt ON t.id = tt.task_id
INNER JOIN tags tg ON tt.tag_id = tg.id
WHERE tg.id = $1 AND t.user_id = $2
ORDER BY
CASE t.priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
t.due_date ASC NULLS LAST,
t.created_at DESC
LIMIT $3 OFFSET $4
`
type GetTasksByTagParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByTag,
arg.ID,
arg.UserID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetTasksByUser = `-- name: GetTasksByUser :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $2 OFFSET $3
`
type GetTasksByUserParams struct {
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByUser, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveTaskTag = `-- name: RemoveTaskTag :exec
DELETE FROM task_tags WHERE task_id = $1 AND tag_id = $2
`
type RemoveTaskTagParams struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error {
_, err := q.db.Exec(ctx, RemoveTaskTag, arg.TaskID, arg.TagID)
return err
}
const SearchTasks = `-- name: SearchTasks :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2
)
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4
`
type SearchTasksParams struct {
UserID pgtype.UUID `json:"userId"`
Title string `json:"title"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error) {
rows, err := q.db.Query(ctx, SearchTasks,
arg.UserID,
arg.Title,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateTask = `-- name: UpdateTask :one
UPDATE tasks
SET title = $2,
description = $3,
status = $4,
priority = $5,
due_date = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $7
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
`
type UpdateTaskParams struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, UpdateTask,
arg.ID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.UserID,
)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+273
View File
@@ -0,0 +1,273 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, first_name, last_name, avatar_url, is_active, is_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
`
type CreateUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
}
type CreateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, CreateUser,
arg.Email,
arg.PasswordHash,
arg.FirstName,
arg.LastName,
arg.AvatarUrl,
arg.IsActive,
arg.IsVerified,
)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1
`
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteUser, id)
return err
}
const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE email = $1 LIMIT 1
`
type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, GetUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByID = `-- name: GetUserByID :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE id = $1 LIMIT 1
`
type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, GetUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListUsers = `-- name: ListUsers :many
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
type ListUsersParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListUsersRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
rows, err := q.db.Query(ctx, ListUsers, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUsersRow{}
for rows.Next() {
var i ListUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLastLogin = `-- name: UpdateLastLogin :exec
UPDATE users
SET last_login = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UpdateLastLogin(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UpdateLastLogin, id)
return err
}
const UpdateUser = `-- name: UpdateUser :one
UPDATE users
SET first_name = $2,
last_name = $3,
avatar_url = $4,
is_active = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
`
type UpdateUserParams struct {
ID pgtype.UUID `json:"id"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
}
type UpdateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
row := q.db.QueryRow(ctx, UpdateUser,
arg.ID,
arg.FirstName,
arg.LastName,
arg.AvatarUrl,
arg.IsActive,
)
var i UpdateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+11 -4
View File
@@ -149,7 +149,7 @@ func main() {
// }()
// Set Gin mode
if os.Getenv("GIN_MODE") == "release" {
if os.Getenv("GIN_MODE") == "release" || os.Getenv("GIN_MODE") == "production" {
gin.SetMode(gin.ReleaseMode)
}
@@ -159,6 +159,7 @@ func main() {
// Middleware
r.Use(gin.Logger())
r.Use(gin.Recovery())
r.Use(middleware.CORSMiddleware())
r.Use(middleware.CacheMiddleware(cacheConfig)) // Add DragonflyDB cache middleware
r.Use(middleware.CacheInvalidationMiddleware(dragonflyClient)) // Add cache invalidation
r.Use(middleware.SessionMiddleware()) // Add session middleware
@@ -172,8 +173,6 @@ func main() {
// Apply general rate limiting to all endpoints
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
r.Use(middleware.CORSMiddleware())
r.GET("/health", handlers.HealthCheck)
r.GET("/ready", handlers.ReadinessCheck)
r.GET("/live", handlers.LivenessCheck)
@@ -231,7 +230,7 @@ func main() {
auth.POST("/login", handlers.Login)
auth.POST("/login-totp", handlers.LoginWithTOTP)
auth.POST("/logout", handlers.Logout)
auth.GET("/me", handlers.GetCurrentUserWithGitHub)
auth.GET("/me", handlers.AuthMiddleware(), handlers.GetCurrentUserWithGitHub)
auth.POST("/password-reset", handlers.RequestPasswordReset)
auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset)
@@ -241,11 +240,19 @@ func main() {
auth.GET("/oauth/callback", handlers.HandleOAuthCallback)
}
// GitHub App callback (public for GitHub redirect)
v1.GET("/github/app/callback", handlers.GitHubAppInstallCallback)
// GitHub routes (protected)
github := v1.Group("/github")
github.Use(handlers.AuthMiddleware())
{
github.GET("/repos", handlers.GetGitHubRepos)
github.GET("/app/status", handlers.GetGitHubAppStatus)
github.GET("/app/install-url", handlers.GetGitHubAppInstallURL)
github.GET("/app/repos", handlers.GetGitHubAppRepos)
github.GET("/backups", handlers.GetGitHubBackups)
github.POST("/backups", handlers.BackupGitHubRepositories)
}
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
+21 -1
View File
@@ -44,6 +44,12 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
return
}
// Skip caching for auth/bootstrap requests and authenticated traffic.
if shouldSkipCache(c) {
c.Next()
return
}
// Generate cache key
cacheKey := generateCacheKey(c, config.KeyPrefix)
@@ -60,7 +66,7 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
// Cache miss, continue with request
c.Header("X-Cache", "MISS")
// Capture response
writer := &cachedResponseWriter{
ResponseWriter: c.Writer,
@@ -82,6 +88,20 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
}
}
func shouldSkipCache(c *gin.Context) bool {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/v1/auth/") {
return true
}
if c.GetHeader("Authorization") != "" || c.GetHeader("Cookie") != "" {
return true
}
return false
}
// generateCacheKey creates a unique cache key for the request
func generateCacheKey(c *gin.Context, prefix string) string {
// Include path, query params, and user ID if available
+3 -11
View File
@@ -27,27 +27,19 @@ func CORSMiddleware() gin.HandlerFunc {
}
origin := c.Request.Header.Get("Origin")
allowed := false
// Always set CORS headers
if allowedOrigins == "*" {
allowed = true
c.Header("Access-Control-Allow-Origin", "*")
} else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
allowed = true
c.Header("Access-Control-Allow-Origin", origin)
break
}
}
}
if allowed {
if allowedOrigins == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
c.Header("Access-Control-Allow-Credentials", "true")
+89 -100
View File
@@ -2,14 +2,12 @@ package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/config"
"go.uber.org/zap"
)
// LoggerConfig holds configuration for the logger
@@ -19,17 +17,14 @@ type LoggerConfig struct {
EnableJSON bool
}
// Logger returns a middleware that logs HTTP requests
// GetLogger returns the logger instance
func (lc LoggerConfig) GetLogger() *zap.Logger {
return config.GetLogger()
}
// Logger returns a middleware that logs HTTP requests using Zap
func Logger(config LoggerConfig) gin.HandlerFunc {
// Create log file if specified
var file *os.File
if config.LogFile != "" {
var err error
file, err = os.OpenFile(config.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Printf("Failed to open log file: %v", err)
}
}
logger := config.GetLogger()
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Create log entry
@@ -45,7 +40,9 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
}
// Add user ID if available
if userID, exists := param.Keys["user_id"]; exists {
var userID interface{}
if uid, exists := param.Keys["user_id"]; exists {
userID = uid
entry["user_id"] = userID
}
@@ -54,40 +51,39 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
entry["error"] = param.ErrorMessage
}
// Format output
var output string
if config.EnableJSON {
jsonData, _ := json.Marshal(entry)
output = string(jsonData) + "\n"
} else {
output = fmt.Sprintf("[%s] %s %s %d %s %s %s",
entry["timestamp"],
entry["method"],
entry["path"],
entry["status"],
entry["latency"],
entry["client_ip"],
entry["user_agent"],
// Log with Zap
if param.ErrorMessage != "" {
logger.Error("HTTP request",
zap.String("method", param.Method),
zap.String("path", param.Path),
zap.Int("status", param.StatusCode),
zap.Duration("latency", param.Latency),
zap.String("client_ip", param.ClientIP),
zap.String("user_agent", param.Request.UserAgent()),
zap.Any("user_id", userID),
zap.String("error", param.ErrorMessage),
)
} else {
logger.Info("HTTP request",
zap.String("method", param.Method),
zap.String("path", param.Path),
zap.Int("status", param.StatusCode),
zap.Duration("latency", param.Latency),
zap.String("client_ip", param.ClientIP),
zap.String("user_agent", param.Request.UserAgent()),
zap.Any("user_id", userID),
)
if userID, exists := entry["user_id"]; exists {
output += fmt.Sprintf(" user_id:%v", userID)
}
if param.ErrorMessage != "" {
output += fmt.Sprintf(" error:%s", param.ErrorMessage)
}
output += "\n"
}
// Write to file and console
if file != nil {
file.WriteString(output)
}
return output
// Return empty string since Zap handles output
return ""
})
}
// RequestLogger logs detailed request information
// RequestLogger logs detailed request information using Zap
func RequestLogger() gin.HandlerFunc {
logger := config.GetLogger()
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
@@ -104,12 +100,6 @@ func RequestLogger() gin.HandlerFunc {
// Calculate latency
latency := time.Since(start)
// Get client IP
clientIP := c.ClientIP()
// Get status code
statusCode := c.Writer.Status()
// Get request ID
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
@@ -122,46 +112,52 @@ func RequestLogger() gin.HandlerFunc {
userID = uid
}
// Create log entry
logEntry := map[string]interface{}{
"timestamp": start.Format(time.RFC3339),
"request_id": requestID,
"method": c.Request.Method,
"path": path,
"query": raw,
"status": statusCode,
"latency_ms": latency.Milliseconds(),
"client_ip": clientIP,
"user_agent": c.Request.UserAgent(),
"referer": c.Request.Referer(),
"content_type": c.GetHeader("Content-Type"),
"content_length": c.Request.ContentLength,
// Log request body for POST/PUT requests (excluding sensitive data)
var requestBody string
if c.Request.Method == "POST" || c.Request.Method == "PUT" {
requestBody = logRequestBody(c)
}
// Create log fields
fields := []zap.Field{
zap.String("request_id", requestID),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", raw),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency_ms", latency),
zap.String("client_ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("referer", c.Request.Referer()),
zap.String("content_type", c.GetHeader("Content-Type")),
zap.Int64("content_length", c.Request.ContentLength),
}
if userID != nil {
logEntry["user_id"] = userID
fields = append(fields, zap.Any("user_id", userID))
}
// Log request body for POST/PUT requests (excluding sensitive data)
if c.Request.Method == "POST" || c.Request.Method == "PUT" {
body := logRequestBody(c)
if body != "" {
logEntry["request_body"] = body
}
if requestBody != "" {
fields = append(fields, zap.String("request_body", requestBody))
}
// Log response size
if c.Writer.Size() > 0 {
logEntry["response_size"] = c.Writer.Size()
fields = append(fields, zap.Int("response_size", c.Writer.Size()))
}
// Log errors
if len(c.Errors) > 0 {
logEntry["errors"] = c.Errors.String()
fields = append(fields, zap.String("errors", c.Errors.String()))
}
// Write structured log
logJSON(logEntry)
// Log based on status code
statusCode := c.Writer.Status()
if statusCode >= 500 {
logger.Error("HTTP request", fields...)
} else if statusCode >= 400 {
logger.Warn("HTTP request", fields...)
} else {
logger.Info("HTTP request", fields...)
}
}
}
@@ -194,17 +190,6 @@ func logRequestBody(c *gin.Context) string {
return string(bodyBytes)
}
// logJSON writes structured JSON logs
func logJSON(data map[string]interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
log.Printf("Failed to marshal log entry: %v", err)
return
}
log.Println(string(jsonData))
}
// SecurityLogger logs security-related events
func SecurityLogger() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -232,19 +217,21 @@ func SecurityLogger() gin.HandlerFunc {
}
}
// logSecurityEvent logs security-related events
// logSecurityEvent logs security-related events using Zap
func logSecurityEvent(eventType string, data map[string]interface{}) {
event := map[string]interface{}{
"event_type": "security",
"event": eventType,
"timestamp": time.Now().Format(time.RFC3339),
logger := config.GetLogger()
fields := []zap.Field{
zap.String("event_type", "security"),
zap.String("event", eventType),
zap.String("timestamp", time.Now().Format(time.RFC3339)),
}
for k, v := range data {
event[k] = v
fields = append(fields, zap.Any(k, v))
}
logJSON(event)
logger.Warn("Security event", fields...)
}
// PerformanceLogger logs performance metrics
@@ -269,17 +256,19 @@ func PerformanceLogger() gin.HandlerFunc {
}
}
// logPerformanceEvent logs performance-related events
// logPerformanceEvent logs performance-related events using Zap
func logPerformanceEvent(eventType string, data map[string]interface{}) {
event := map[string]interface{}{
"event_type": "performance",
"event": eventType,
"timestamp": time.Now().Format(time.RFC3339),
logger := config.GetLogger()
fields := []zap.Field{
zap.String("event_type", "performance"),
zap.String("event", eventType),
zap.String("timestamp", time.Now().Format(time.RFC3339)),
}
for k, v := range data {
event[k] = v
fields = append(fields, zap.Any(k, v))
}
logJSON(event)
logger.Info("Performance event", fields...)
}
+149
View File
@@ -0,0 +1,149 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
is_verified BOOLEAN DEFAULT false,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tags table
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
color VARCHAR(7) DEFAULT '#39b9ff',
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, user_id)
);
-- Bookmarks table
CREATE TABLE IF NOT EXISTS bookmarks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
favicon_url TEXT,
screenshot_url TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
is_archived BOOLEAN DEFAULT false,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bookmark tags junction table
CREATE TABLE IF NOT EXISTS bookmark_tags (
bookmark_id UUID REFERENCES bookmarks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (bookmark_id, tag_id)
);
-- Tasks table
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
priority VARCHAR(10) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
due_date TIMESTAMP,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Task tags junction table
CREATE TABLE IF NOT EXISTS task_tags (
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Note tags junction table
CREATE TABLE IF NOT EXISTS note_tags (
note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
-- Files table
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
file_path TEXT NOT NULL,
thumbnail_path TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- File tags junction table
CREATE TABLE IF NOT EXISTS file_tags (
file_id UUID REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_id ON bookmarks(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
-- +goose Down
-- Drop tables in reverse order due to foreign key constraints
DROP TABLE IF EXISTS file_tags;
DROP TABLE IF EXISTS note_tags;
DROP TABLE IF EXISTS task_tags;
DROP TABLE IF EXISTS bookmark_tags;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS bookmarks;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
+4
View File
@@ -0,0 +1,4 @@
[goose]
dialect = "postgres"
dir = "migrations"
table = "goose_db_version"
+114
View File
@@ -0,0 +1,114 @@
package migrations
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
)
// RunMigrations runs all database migrations using Goose
func RunMigrations() error {
// Get database connection string
dbType := os.Getenv("DB_TYPE")
if dbType == "" {
dbType = "postgres"
}
if dbType != "postgres" {
return fmt.Errorf("goose migrations currently only support PostgreSQL, got: %s", dbType)
}
// Build connection string
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
os.Getenv("DB_HOST"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
os.Getenv("DB_PORT"),
os.Getenv("DB_SSL_MODE"),
)
// Open database connection
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("failed to open database for migrations: %w", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
return fmt.Errorf("failed to ping database for migrations: %w", err)
}
// Set goose dialect
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
// Run migrations
log.Println("Running database migrations...")
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Println("Database migrations completed successfully")
return nil
}
// GetMigrationStatus returns the current migration status
func GetMigrationStatus() error {
// Get database connection string
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
os.Getenv("DB_HOST"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
os.Getenv("DB_PORT"),
os.Getenv("DB_SSL_MODE"),
)
// Open database connection
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("failed to open database for migration status: %w", err)
}
defer db.Close()
// Set goose dialect
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
// Get migration status
log.Println("Checking migration status...")
if err := goose.Status(db, "migrations"); err != nil {
return fmt.Errorf("failed to get migration status: %w", err)
}
return nil
}
// CreateMigration creates a new migration file
func CreateMigration(name, migrationType string) error {
var err error
switch migrationType {
case "up":
err = goose.Create(nil, "migrations", name, "up")
case "down":
err = goose.Create(nil, "migrations", name, "down")
default:
return fmt.Errorf("invalid migration type: %s (must be 'up' or 'down')", migrationType)
}
if err != nil {
return fmt.Errorf("failed to create migration: %w", err)
}
log.Printf("Migration file created: %s", name)
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"time"
"gorm.io/gorm"
)
// GitHubAppInstallState stores short-lived install state values for GitHub App callbacks.
type GitHubAppInstallState struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"user_id" gorm:"not null;index"`
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
State string `json:"state" gorm:"not null;size:128;uniqueIndex"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
UsedAt *time.Time `json:"used_at"`
}
// GitHubAppInstallation stores GitHub App installation metadata linked to a Trackeep user.
type GitHubAppInstallation struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"user_id" gorm:"not null;index"`
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
InstallationID int64 `json:"installation_id" gorm:"not null;uniqueIndex"`
AppSlug string `json:"app_slug" gorm:"size:255"`
AccountLogin string `json:"account_login" gorm:"size:255"`
AccountType string `json:"account_type" gorm:"size:64"`
LastValidated *time.Time `json:"last_validated,omitempty"`
}
// GitHubRepoBackup tracks local repository backups created by Trackeep.
type GitHubRepoBackup struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"user_id" gorm:"not null;index:idx_github_backup_user_repo,unique"`
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
RepositoryID int64 `json:"repository_id" gorm:"index"`
RepositoryName string `json:"repository_name" gorm:"size:255"`
RepositoryFullName string `json:"repository_full_name" gorm:"not null;size:255;index:idx_github_backup_user_repo,unique"`
DefaultBranch string `json:"default_branch" gorm:"size:255"`
CloneURL string `json:"clone_url" gorm:"type:text"`
LocalPath string `json:"local_path" gorm:"not null;type:text"`
Source string `json:"source" gorm:"not null;size:32"` // oauth or github_app
InstallationID *int64 `json:"installation_id,omitempty"`
LastBackupAt *time.Time `json:"last_backup_at"`
LastBackupStatus string `json:"last_backup_status" gorm:"not null;default:'pending';size:32"` // pending, success, error
LastBackupError string `json:"last_backup_error"`
LastBackupSize int64 `json:"last_backup_size"`
}
+3
View File
@@ -63,6 +63,9 @@ func AutoMigrate() {
&Integration{},
&SyncLog{},
&WebhookEvent{},
&GitHubAppInstallState{},
&GitHubAppInstallation{},
&GitHubRepoBackup{},
// Analytics models
&Analytics{},
&ProductivityMetrics{},
+58
View File
@@ -0,0 +1,58 @@
-- name: GetBookmarkByID :one
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE id = $1 AND user_id = $2 LIMIT 1;
-- name: GetBookmarksByUser :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetBookmarksByTag :many
SELECT b.id, b.title, b.url, b.description, b.favicon_url, b.screenshot_url, b.user_id, b.is_archived, b.is_favorite, b.created_at, b.updated_at
FROM bookmarks b
INNER JOIN bookmark_tags bt ON b.id = bt.bookmark_id
INNER JOIN tags t ON bt.tag_id = t.id
WHERE t.id = $1 AND b.user_id = $2
ORDER BY b.created_at DESC
LIMIT $3 OFFSET $4;
-- name: SearchBookmarks :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2 OR
url ILIKE $2
)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4;
-- name: CreateBookmark :one
INSERT INTO bookmarks (title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at;
-- name: UpdateBookmark :one
UPDATE bookmarks
SET title = $2,
url = $3,
description = $4,
favicon_url = $5,
screenshot_url = $6,
is_archived = $7,
is_favorite = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $9
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at;
-- name: DeleteBookmark :exec
DELETE FROM bookmarks WHERE id = $1 AND user_id = $2;
-- name: AddBookmarkTag :exec
INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;
-- name: RemoveBookmarkTag :exec
DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2;
+90
View File
@@ -0,0 +1,90 @@
-- name: GetTaskByID :one
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE id = $1 AND user_id = $2 LIMIT 1;
-- name: GetTasksByUser :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetTasksByStatus :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND status = $2
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4;
-- name: GetTasksByTag :many
SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, t.user_id, t.created_at, t.updated_at
FROM tasks t
INNER JOIN task_tags tt ON t.id = tt.task_id
INNER JOIN tags tg ON tt.tag_id = tg.id
WHERE tg.id = $1 AND t.user_id = $2
ORDER BY
CASE t.priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
t.due_date ASC NULLS LAST,
t.created_at DESC
LIMIT $3 OFFSET $4;
-- name: SearchTasks :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2
)
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4;
-- name: CreateTask :one
INSERT INTO tasks (title, description, status, priority, due_date, user_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at;
-- name: UpdateTask :one
UPDATE tasks
SET title = $2,
description = $3,
status = $4,
priority = $5,
due_date = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $7
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at;
-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND user_id = $2;
-- name: AddTaskTag :exec
INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;
-- name: RemoveTaskTag :exec
DELETE FROM task_tags WHERE task_id = $1 AND tag_id = $2;
+38
View File
@@ -0,0 +1,38 @@
-- name: GetUserByID :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE id = $1 LIMIT 1;
-- name: GetUserByEmail :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE email = $1 LIMIT 1;
-- name: CreateUser :one
INSERT INTO users (email, password_hash, first_name, last_name, avatar_url, is_active, is_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at;
-- name: UpdateUser :one
UPDATE users
SET first_name = $2,
last_name = $3,
avatar_url = $4,
is_active = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at;
-- name: UpdateLastLogin :exec
UPDATE users
SET last_login = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;
+121
View File
@@ -0,0 +1,121 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
is_verified BOOLEAN DEFAULT false,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tags table
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
color VARCHAR(7) DEFAULT '#39b9ff',
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, user_id)
);
-- Bookmarks table
CREATE TABLE IF NOT EXISTS bookmarks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
favicon_url TEXT,
screenshot_url TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
is_archived BOOLEAN DEFAULT false,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bookmark tags junction table
CREATE TABLE IF NOT EXISTS bookmark_tags (
bookmark_id UUID REFERENCES bookmarks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (bookmark_id, tag_id)
);
-- Tasks table
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
priority VARCHAR(10) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
due_date TIMESTAMP,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Task tags junction table
CREATE TABLE IF NOT EXISTS task_tags (
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Note tags junction table
CREATE TABLE IF NOT EXISTS note_tags (
note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
-- Files table
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
file_path TEXT NOT NULL,
thumbnail_path TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- File tags junction table
CREATE TABLE IF NOT EXISTS file_tags (
file_id UUID REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+23
View File
@@ -0,0 +1,23 @@
version: "2"
sql:
- engine: "postgresql"
queries: "queries/"
schema: "schema.sql"
gen:
go:
package: "sqlc"
out: "internal/db/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true
emit_exact_table_names: false
emit_empty_slices: true
emit_exported_queries: true
emit_result_struct_pointers: false
emit_params_struct_pointers: false
emit_methods_with_db_argument: false
emit_pointers_for_null_types: true
emit_enum_valid_method: true
emit_all_enum_values: true
json_tags_case_style: "camel"