mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
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:
+4
-1
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
[goose]
|
||||
dialect = "postgres"
|
||||
dir = "migrations"
|
||||
table = "goose_db_version"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -63,6 +63,9 @@ func AutoMigrate() {
|
||||
&Integration{},
|
||||
&SyncLog{},
|
||||
&WebhookEvent{},
|
||||
&GitHubAppInstallState{},
|
||||
&GitHubAppInstallation{},
|
||||
&GitHubRepoBackup{},
|
||||
// Analytics models
|
||||
&Analytics{},
|
||||
&ProductivityMetrics{},
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user